free-coding-models 0.2.17 → 0.3.1
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/CHANGELOG.md +71 -0
- package/README.md +118 -44
- package/bin/fcm-proxy-daemon.js +239 -0
- package/bin/free-coding-models.js +146 -37
- package/package.json +3 -2
- package/src/account-manager.js +34 -0
- package/src/anthropic-translator.js +440 -0
- package/src/cli-help.js +108 -0
- package/src/config.js +25 -1
- package/src/daemon-manager.js +527 -0
- package/src/endpoint-installer.js +45 -19
- package/src/key-handler.js +324 -148
- package/src/opencode.js +47 -44
- package/src/overlays.js +282 -207
- package/src/proxy-server.js +746 -10
- package/src/proxy-sync.js +564 -0
- package/src/proxy-topology.js +80 -0
- package/src/render-helpers.js +4 -2
- package/src/render-table.js +56 -49
- package/src/responses-translator.js +423 -0
- package/src/tool-launchers.js +343 -26
- package/src/utils.js +31 -8
package/src/tool-launchers.js
CHANGED
|
@@ -15,11 +15,23 @@
|
|
|
15
15
|
* For those, we prefer a transparent warning over pretending the integration is
|
|
16
16
|
* fully official. The user still gets a reproducible env/config handoff.
|
|
17
17
|
*
|
|
18
|
+
* 📖 Goose: writes custom provider JSON + secrets.yaml + updates config.yaml (GOOSE_PROVIDER/GOOSE_MODEL)
|
|
19
|
+
* 📖 Crush: writes crush.json with provider config + models.large/small defaults
|
|
20
|
+
* 📖 Pi: uses --provider/--model CLI flags for guaranteed auto-selection
|
|
21
|
+
* 📖 Aider: writes ~/.aider.conf.yml + passes --model flag
|
|
22
|
+
* 📖 Claude Code: uses ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN only (mirrors free-claude-code)
|
|
23
|
+
* 📖 Codex CLI: uses a custom model_provider override so Codex stays in explicit API-provider mode
|
|
24
|
+
* 📖 Gemini CLI: proxy mode is capability-gated because older builds do not support custom base URL routing cleanly
|
|
25
|
+
*
|
|
18
26
|
* @functions
|
|
19
27
|
* → `resolveLauncherModelId` — choose the provider-specific id or proxy slug for a launch
|
|
28
|
+
* → `buildCodexProxyArgs` — force Codex into a proxy-backed custom provider config
|
|
29
|
+
* → `inspectGeminiCliSupport` — detect whether the installed Gemini CLI can use proxy mode safely
|
|
30
|
+
* → `writeGooseConfig` — install provider + set GOOSE_PROVIDER/GOOSE_MODEL in config.yaml
|
|
31
|
+
* → `writeCrushConfig` — write provider + models.large/small to crush.json
|
|
20
32
|
* → `startExternalTool` — configure and launch the selected external tool mode
|
|
21
33
|
*
|
|
22
|
-
* @exports resolveLauncherModelId, startExternalTool
|
|
34
|
+
* @exports resolveLauncherModelId, buildCodexProxyArgs, inspectGeminiCliSupport, startExternalTool
|
|
23
35
|
*
|
|
24
36
|
* @see src/tool-metadata.js
|
|
25
37
|
* @see src/provider-metadata.js
|
|
@@ -27,16 +39,42 @@
|
|
|
27
39
|
*/
|
|
28
40
|
|
|
29
41
|
import chalk from 'chalk'
|
|
30
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'fs'
|
|
42
|
+
import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync, copyFileSync } from 'fs'
|
|
31
43
|
import { homedir } from 'os'
|
|
32
|
-
import { dirname, join } from 'path'
|
|
33
|
-
import { spawn } from 'child_process'
|
|
44
|
+
import { delimiter, dirname, join } from 'path'
|
|
45
|
+
import { spawn, spawnSync } from 'child_process'
|
|
34
46
|
import { sources } from '../sources.js'
|
|
35
47
|
import { PROVIDER_COLOR } from './render-table.js'
|
|
36
48
|
import { getApiKey, getProxySettings } from './config.js'
|
|
37
49
|
import { ENV_VAR_NAMES, isWindows } from './provider-metadata.js'
|
|
38
50
|
import { getToolMeta } from './tool-metadata.js'
|
|
39
51
|
import { ensureProxyRunning, resolveProxyModelId } from './opencode.js'
|
|
52
|
+
import { PROVIDER_METADATA } from './provider-metadata.js'
|
|
53
|
+
|
|
54
|
+
const OPENAI_COMPAT_ENV_KEYS = [
|
|
55
|
+
'OPENAI_API_KEY',
|
|
56
|
+
'OPENAI_BASE_URL',
|
|
57
|
+
'OPENAI_API_BASE',
|
|
58
|
+
'OPENAI_MODEL',
|
|
59
|
+
'LLM_API_KEY',
|
|
60
|
+
'LLM_BASE_URL',
|
|
61
|
+
'LLM_MODEL',
|
|
62
|
+
]
|
|
63
|
+
const ANTHROPIC_ENV_KEYS = [
|
|
64
|
+
'ANTHROPIC_API_KEY',
|
|
65
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
66
|
+
'ANTHROPIC_BASE_URL',
|
|
67
|
+
'ANTHROPIC_MODEL',
|
|
68
|
+
]
|
|
69
|
+
const GEMINI_ENV_KEYS = [
|
|
70
|
+
'GEMINI_API_KEY',
|
|
71
|
+
'GOOGLE_API_KEY',
|
|
72
|
+
'GOOGLE_GEMINI_BASE_URL',
|
|
73
|
+
'GOOGLE_VERTEX_BASE_URL',
|
|
74
|
+
]
|
|
75
|
+
const PROXY_SANITIZED_ENV_KEYS = [...OPENAI_COMPAT_ENV_KEYS, ...ANTHROPIC_ENV_KEYS, ...GEMINI_ENV_KEYS]
|
|
76
|
+
const GEMINI_PROXY_MIN_VERSION = '0.34.0'
|
|
77
|
+
const EXPERIMENTAL_PROXY_TOOLS_NOTE = 'FCM Proxy V2 support for external tools is still in beta, so some launch and authentication flows can remain flaky while the integration stabilizes.'
|
|
40
78
|
|
|
41
79
|
function ensureDir(filePath) {
|
|
42
80
|
const dir = dirname(filePath)
|
|
@@ -73,6 +111,16 @@ function getProviderBaseUrl(providerKey) {
|
|
|
73
111
|
.replace(/\/predictions$/i, '')
|
|
74
112
|
}
|
|
75
113
|
|
|
114
|
+
function deleteEnvKeys(env, keys) {
|
|
115
|
+
for (const key of keys) delete env[key]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function cloneInheritedEnv(inheritedEnv = process.env, sanitizeKeys = []) {
|
|
119
|
+
const env = { ...inheritedEnv }
|
|
120
|
+
deleteEnvKeys(env, sanitizeKeys)
|
|
121
|
+
return env
|
|
122
|
+
}
|
|
123
|
+
|
|
76
124
|
function applyOpenAiCompatEnv(env, apiKey, baseUrl, modelId) {
|
|
77
125
|
if (!apiKey || !baseUrl || !modelId) return env
|
|
78
126
|
env.OPENAI_API_KEY = apiKey
|
|
@@ -98,17 +146,23 @@ export function resolveLauncherModelId(model, useProxy = false) {
|
|
|
98
146
|
return model?.modelId ?? ''
|
|
99
147
|
}
|
|
100
148
|
|
|
101
|
-
function buildToolEnv(mode, model, config) {
|
|
149
|
+
export function buildToolEnv(mode, model, config, options = {}) {
|
|
150
|
+
const {
|
|
151
|
+
sanitize = false,
|
|
152
|
+
includeCompatDefaults = true,
|
|
153
|
+
includeProviderEnv = true,
|
|
154
|
+
inheritedEnv = process.env,
|
|
155
|
+
} = options
|
|
102
156
|
const providerKey = model.providerKey
|
|
103
157
|
const providerUrl = sources[providerKey]?.url || ''
|
|
104
158
|
const baseUrl = getProviderBaseUrl(providerKey)
|
|
105
159
|
const apiKey = getApiKey(config, providerKey)
|
|
106
|
-
const env =
|
|
160
|
+
const env = cloneInheritedEnv(inheritedEnv, sanitize ? PROXY_SANITIZED_ENV_KEYS : [])
|
|
107
161
|
const providerEnvName = ENV_VAR_NAMES[providerKey]
|
|
108
|
-
if (providerEnvName && apiKey) env[providerEnvName] = apiKey
|
|
162
|
+
if (includeProviderEnv && providerEnvName && apiKey) env[providerEnvName] = apiKey
|
|
109
163
|
|
|
110
164
|
// 📖 OpenAI-compatible defaults reused by multiple CLIs.
|
|
111
|
-
if (apiKey && baseUrl) {
|
|
165
|
+
if (includeCompatDefaults && apiKey && baseUrl) {
|
|
112
166
|
env.OPENAI_API_KEY = apiKey
|
|
113
167
|
env.OPENAI_BASE_URL = baseUrl
|
|
114
168
|
env.OPENAI_API_BASE = baseUrl
|
|
@@ -126,6 +180,7 @@ function buildToolEnv(mode, model, config) {
|
|
|
126
180
|
}
|
|
127
181
|
|
|
128
182
|
if (mode === 'gemini' && apiKey && baseUrl) {
|
|
183
|
+
env.GEMINI_API_KEY = apiKey
|
|
129
184
|
env.GOOGLE_API_KEY = apiKey
|
|
130
185
|
env.GOOGLE_GEMINI_BASE_URL = baseUrl
|
|
131
186
|
}
|
|
@@ -133,6 +188,114 @@ function buildToolEnv(mode, model, config) {
|
|
|
133
188
|
return { env, apiKey, baseUrl, providerUrl }
|
|
134
189
|
}
|
|
135
190
|
|
|
191
|
+
export function buildCodexProxyArgs(baseUrl) {
|
|
192
|
+
return [
|
|
193
|
+
'-c', 'model_provider="fcm_proxy"',
|
|
194
|
+
'-c', 'model_providers.fcm_proxy.name="FCM Proxy V2"',
|
|
195
|
+
'-c', `model_providers.fcm_proxy.base_url=${JSON.stringify(baseUrl)}`,
|
|
196
|
+
'-c', 'model_providers.fcm_proxy.env_key="FCM_PROXY_API_KEY"',
|
|
197
|
+
'-c', 'model_providers.fcm_proxy.wire_api="responses"',
|
|
198
|
+
]
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function compareSemver(a, b) {
|
|
202
|
+
const left = String(a || '').split('.').map(part => Number.parseInt(part, 10) || 0)
|
|
203
|
+
const right = String(b || '').split('.').map(part => Number.parseInt(part, 10) || 0)
|
|
204
|
+
const length = Math.max(left.length, right.length)
|
|
205
|
+
for (let idx = 0; idx < length; idx++) {
|
|
206
|
+
const lhs = left[idx] || 0
|
|
207
|
+
const rhs = right[idx] || 0
|
|
208
|
+
if (lhs > rhs) return 1
|
|
209
|
+
if (lhs < rhs) return -1
|
|
210
|
+
}
|
|
211
|
+
return 0
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function findExecutableOnPath(command) {
|
|
215
|
+
const pathValue = process.env.PATH || ''
|
|
216
|
+
const candidates = process.platform === 'win32'
|
|
217
|
+
? [command, `${command}.cmd`, `${command}.exe`]
|
|
218
|
+
: [command]
|
|
219
|
+
|
|
220
|
+
for (const dir of pathValue.split(delimiter).filter(Boolean)) {
|
|
221
|
+
for (const candidate of candidates) {
|
|
222
|
+
const fullPath = join(dir, candidate)
|
|
223
|
+
try {
|
|
224
|
+
accessSync(fullPath, constants.X_OK)
|
|
225
|
+
return fullPath
|
|
226
|
+
} catch { /* not executable */ }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function findPackageJsonUpwards(startPath) {
|
|
233
|
+
let current = dirname(startPath)
|
|
234
|
+
while (current && current !== dirname(current)) {
|
|
235
|
+
const packageJsonPath = join(current, 'package.json')
|
|
236
|
+
if (existsSync(packageJsonPath)) return packageJsonPath
|
|
237
|
+
current = dirname(current)
|
|
238
|
+
}
|
|
239
|
+
return null
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function detectGeminiCliVersion(binaryPath) {
|
|
243
|
+
if (!binaryPath) return null
|
|
244
|
+
try {
|
|
245
|
+
const realPath = realpathSync(binaryPath)
|
|
246
|
+
const versionMatch = realPath.match(/gemini-cli[\\/](\d+\.\d+\.\d+)(?:[\\/]|$)/)
|
|
247
|
+
if (versionMatch?.[1]) return versionMatch[1]
|
|
248
|
+
|
|
249
|
+
const packageJsonPath = findPackageJsonUpwards(realPath)
|
|
250
|
+
if (!packageJsonPath) return null
|
|
251
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
|
|
252
|
+
if (typeof pkg?.version === 'string' && pkg.version.length > 0) {
|
|
253
|
+
return pkg.version
|
|
254
|
+
}
|
|
255
|
+
} catch { /* best effort */ }
|
|
256
|
+
return null
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function extractGeminiConfigError(output) {
|
|
260
|
+
const text = String(output || '').trim()
|
|
261
|
+
if (!text.includes('Invalid configuration in ')) return null
|
|
262
|
+
const lines = text.split(/\r?\n/).filter(Boolean)
|
|
263
|
+
return lines.slice(0, 8).join('\n')
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function inspectGeminiCliSupport(options = {}) {
|
|
267
|
+
const binaryPath = options.binaryPath || findExecutableOnPath(options.command || 'gemini')
|
|
268
|
+
if (!binaryPath) {
|
|
269
|
+
return {
|
|
270
|
+
installed: false,
|
|
271
|
+
version: null,
|
|
272
|
+
supportsProxyBaseUrl: false,
|
|
273
|
+
configError: null,
|
|
274
|
+
reason: 'Gemini CLI is not installed in PATH.',
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const version = options.version || detectGeminiCliVersion(binaryPath)
|
|
279
|
+
const helpResult = options.helpResult || spawnSync(binaryPath, ['--help'], {
|
|
280
|
+
encoding: 'utf8',
|
|
281
|
+
timeout: 5000,
|
|
282
|
+
env: options.inheritedEnv || process.env,
|
|
283
|
+
})
|
|
284
|
+
const helpOutput = `${helpResult.stdout || ''}\n${helpResult.stderr || ''}`.trim()
|
|
285
|
+
const configError = extractGeminiConfigError(helpOutput)
|
|
286
|
+
const supportsProxyBaseUrl = version ? compareSemver(version, GEMINI_PROXY_MIN_VERSION) >= 0 : false
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
installed: true,
|
|
290
|
+
version,
|
|
291
|
+
supportsProxyBaseUrl,
|
|
292
|
+
configError,
|
|
293
|
+
reason: supportsProxyBaseUrl
|
|
294
|
+
? null
|
|
295
|
+
: `Gemini CLI ${version || '(unknown version)'} does not expose stable custom base URL support for proxy mode yet.`,
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
136
299
|
function spawnCommand(command, args, env) {
|
|
137
300
|
return new Promise((resolve, reject) => {
|
|
138
301
|
const child = spawn(command, args, {
|
|
@@ -173,8 +336,10 @@ function writeCrushConfig(model, apiKey, baseUrl, providerId) {
|
|
|
173
336
|
const filePath = join(homedir(), '.config', 'crush', 'crush.json')
|
|
174
337
|
const backupPath = backupIfExists(filePath)
|
|
175
338
|
const config = readJson(filePath, { $schema: 'https://charm.land/crush.json' })
|
|
176
|
-
|
|
177
|
-
config.options.disable_default_providers
|
|
339
|
+
// 📖 Remove legacy disable_default_providers — it can prevent Crush from auto-selecting models
|
|
340
|
+
if (config.options && config.options.disable_default_providers) {
|
|
341
|
+
delete config.options.disable_default_providers
|
|
342
|
+
}
|
|
178
343
|
if (!config.providers || typeof config.providers !== 'object') config.providers = {}
|
|
179
344
|
config.providers[providerId] = {
|
|
180
345
|
name: 'Free Coding Models',
|
|
@@ -189,10 +354,11 @@ function writeCrushConfig(model, apiKey, baseUrl, providerId) {
|
|
|
189
354
|
],
|
|
190
355
|
}
|
|
191
356
|
// 📖 Crush expects structured selected models at config.models.{large,small}.
|
|
192
|
-
// 📖
|
|
357
|
+
// 📖 Setting both large AND small ensures Crush auto-selects the model in interactive mode.
|
|
193
358
|
config.models = {
|
|
194
359
|
...(config.models && typeof config.models === 'object' ? config.models : {}),
|
|
195
360
|
large: { model: model.modelId, provider: providerId },
|
|
361
|
+
small: { model: model.modelId, provider: providerId },
|
|
196
362
|
}
|
|
197
363
|
writeJson(filePath, config)
|
|
198
364
|
return { filePath, backupPath }
|
|
@@ -252,6 +418,73 @@ function writePiConfig(model, apiKey, baseUrl) {
|
|
|
252
418
|
return { filePath: modelsFilePath, backupPath: modelsBackupPath, settingsFilePath, settingsBackupPath }
|
|
253
419
|
}
|
|
254
420
|
|
|
421
|
+
// 📖 writeGooseConfig: Install/update the provider in Goose's custom_providers/, set the
|
|
422
|
+
// 📖 API key in secrets.yaml, and update config.yaml with GOOSE_PROVIDER + GOOSE_MODEL
|
|
423
|
+
// 📖 so Goose auto-selects the model on launch.
|
|
424
|
+
function writeGooseConfig(model, apiKey, baseUrl, providerKey) {
|
|
425
|
+
const home = homedir()
|
|
426
|
+
const providerId = `fcm-${providerKey}`
|
|
427
|
+
const providerLabel = PROVIDER_METADATA[providerKey]?.label || sources[providerKey]?.name || providerKey
|
|
428
|
+
const secretEnvName = `FCM_${providerKey.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
|
|
429
|
+
|
|
430
|
+
// 📖 Step 1: Write custom provider JSON (same format as endpoint-installer)
|
|
431
|
+
const providerDir = join(home, '.config', 'goose', 'custom_providers')
|
|
432
|
+
const providerFilePath = join(providerDir, `${providerId}.json`)
|
|
433
|
+
ensureDir(providerFilePath)
|
|
434
|
+
const providerConfig = {
|
|
435
|
+
name: providerId,
|
|
436
|
+
engine: 'openai',
|
|
437
|
+
display_name: `FCM ${providerLabel}`,
|
|
438
|
+
description: `Managed by free-coding-models for ${providerLabel}`,
|
|
439
|
+
api_key_env: secretEnvName,
|
|
440
|
+
base_url: baseUrl?.endsWith('/chat/completions') ? baseUrl : (baseUrl || ''),
|
|
441
|
+
models: [{ name: model.modelId, context_limit: 128000 }],
|
|
442
|
+
supports_streaming: true,
|
|
443
|
+
requires_auth: true,
|
|
444
|
+
}
|
|
445
|
+
writeFileSync(providerFilePath, JSON.stringify(providerConfig, null, 2) + '\n')
|
|
446
|
+
|
|
447
|
+
// 📖 Step 2: Write API key to secrets.yaml (simple key: value format)
|
|
448
|
+
const secretsPath = join(home, '.config', 'goose', 'secrets.yaml')
|
|
449
|
+
let secretsContent = ''
|
|
450
|
+
if (existsSync(secretsPath)) {
|
|
451
|
+
secretsContent = readFileSync(secretsPath, 'utf8')
|
|
452
|
+
}
|
|
453
|
+
// 📖 Replace existing secret or append new one
|
|
454
|
+
const secretLine = `${secretEnvName}: ${JSON.stringify(apiKey)}`
|
|
455
|
+
const secretRegex = new RegExp(`^${secretEnvName}:.*$`, 'm')
|
|
456
|
+
if (secretRegex.test(secretsContent)) {
|
|
457
|
+
secretsContent = secretsContent.replace(secretRegex, secretLine)
|
|
458
|
+
} else {
|
|
459
|
+
secretsContent = secretsContent.trimEnd() + '\n' + secretLine + '\n'
|
|
460
|
+
}
|
|
461
|
+
ensureDir(secretsPath)
|
|
462
|
+
writeFileSync(secretsPath, secretsContent)
|
|
463
|
+
|
|
464
|
+
// 📖 Step 3: Update config.yaml — set GOOSE_PROVIDER and GOOSE_MODEL at top level
|
|
465
|
+
const configPath = join(home, '.config', 'goose', 'config.yaml')
|
|
466
|
+
let configContent = ''
|
|
467
|
+
if (existsSync(configPath)) {
|
|
468
|
+
configContent = readFileSync(configPath, 'utf8')
|
|
469
|
+
}
|
|
470
|
+
// 📖 Replace or add GOOSE_PROVIDER line
|
|
471
|
+
if (/^GOOSE_PROVIDER:.*/m.test(configContent)) {
|
|
472
|
+
configContent = configContent.replace(/^GOOSE_PROVIDER:.*/m, `GOOSE_PROVIDER: ${providerId}`)
|
|
473
|
+
} else {
|
|
474
|
+
configContent = `GOOSE_PROVIDER: ${providerId}\n` + configContent
|
|
475
|
+
}
|
|
476
|
+
// 📖 Replace or add GOOSE_MODEL line
|
|
477
|
+
if (/^GOOSE_MODEL:.*/m.test(configContent)) {
|
|
478
|
+
configContent = configContent.replace(/^GOOSE_MODEL:.*/m, `GOOSE_MODEL: ${model.modelId}`)
|
|
479
|
+
} else {
|
|
480
|
+
// 📖 Insert after GOOSE_PROVIDER line
|
|
481
|
+
configContent = configContent.replace(/^(GOOSE_PROVIDER:.*)/m, `$1\nGOOSE_MODEL: ${model.modelId}`)
|
|
482
|
+
}
|
|
483
|
+
writeFileSync(configPath, configContent)
|
|
484
|
+
|
|
485
|
+
return { providerFilePath, secretsPath, configPath }
|
|
486
|
+
}
|
|
487
|
+
|
|
255
488
|
function writeAmpConfig(model, baseUrl) {
|
|
256
489
|
const filePath = join(homedir(), '.config', 'amp', 'settings.json')
|
|
257
490
|
const backupPath = backupIfExists(filePath)
|
|
@@ -268,6 +501,10 @@ function printConfigResult(toolName, result) {
|
|
|
268
501
|
if (result.backupPath) console.log(chalk.dim(` 💾 Backup: ${result.backupPath}`))
|
|
269
502
|
}
|
|
270
503
|
|
|
504
|
+
function printExperimentalProxyNote() {
|
|
505
|
+
console.log(chalk.dim(` ${EXPERIMENTAL_PROXY_TOOLS_NOTE}`))
|
|
506
|
+
}
|
|
507
|
+
|
|
271
508
|
export async function startExternalTool(mode, model, config) {
|
|
272
509
|
const meta = getToolMeta(mode)
|
|
273
510
|
const { env, apiKey, baseUrl } = buildToolEnv(mode, model, config)
|
|
@@ -315,40 +552,119 @@ export async function startExternalTool(mode, model, config) {
|
|
|
315
552
|
}
|
|
316
553
|
|
|
317
554
|
if (mode === 'goose') {
|
|
318
|
-
let gooseBaseUrl = baseUrl
|
|
555
|
+
let gooseBaseUrl = sources[model.providerKey]?.url || baseUrl || ''
|
|
319
556
|
let gooseApiKey = apiKey
|
|
320
557
|
let gooseModelId = resolveLauncherModelId(model, false)
|
|
558
|
+
let gooseProviderKey = model.providerKey
|
|
321
559
|
|
|
322
560
|
if (proxySettings.enabled) {
|
|
323
561
|
const started = await ensureProxyRunning(config)
|
|
324
562
|
gooseApiKey = started.proxyToken
|
|
325
|
-
gooseBaseUrl = `http://127.0.0.1:${started.port}/v1`
|
|
563
|
+
gooseBaseUrl = `http://127.0.0.1:${started.port}/v1/chat/completions`
|
|
326
564
|
gooseModelId = resolveLauncherModelId(model, true)
|
|
327
|
-
|
|
565
|
+
gooseProviderKey = 'proxy'
|
|
328
566
|
console.log(chalk.dim(` 📖 Goose will use the local FCM proxy on :${started.port} for this launch.`))
|
|
329
567
|
}
|
|
330
568
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
console.log(chalk.dim(`
|
|
569
|
+
// 📖 Write Goose config: custom provider JSON + secrets.yaml + config.yaml (GOOSE_PROVIDER/GOOSE_MODEL)
|
|
570
|
+
const gooseResult = writeGooseConfig({ ...model, modelId: gooseModelId }, gooseApiKey, gooseBaseUrl, gooseProviderKey)
|
|
571
|
+
console.log(chalk.dim(` 📄 Goose config updated: ${gooseResult.configPath}`))
|
|
572
|
+
console.log(chalk.dim(` 📄 Provider installed: ${gooseResult.providerFilePath}`))
|
|
573
|
+
|
|
574
|
+
// 📖 Also set env vars as belt-and-suspenders
|
|
575
|
+
env.GOOSE_PROVIDER = `fcm-${gooseProviderKey}`
|
|
576
|
+
env.GOOSE_MODEL = gooseModelId
|
|
577
|
+
applyOpenAiCompatEnv(env, gooseApiKey, gooseBaseUrl.replace(/\/chat\/completions$/, ''), gooseModelId)
|
|
335
578
|
return spawnCommand('goose', [], env)
|
|
336
579
|
}
|
|
337
580
|
|
|
581
|
+
// 📖 Claude Code, Codex, and Gemini require the FCM Proxy V2 background service.
|
|
582
|
+
// 📖 Without it, these tools cannot connect to the free providers (protocol mismatch / no direct support).
|
|
583
|
+
if (mode === 'claude-code' || mode === 'codex' || mode === 'gemini') {
|
|
584
|
+
if (!proxySettings.enabled) {
|
|
585
|
+
console.log()
|
|
586
|
+
console.log(chalk.red(` ✖ ${meta.label} requires FCM Proxy V2 to work with free providers.`))
|
|
587
|
+
console.log()
|
|
588
|
+
console.log(chalk.yellow(' The proxy translates between provider protocols and handles key rotation,'))
|
|
589
|
+
console.log(chalk.yellow(' which is required for this tool to connect.'))
|
|
590
|
+
console.log(chalk.dim(` ${EXPERIMENTAL_PROXY_TOOLS_NOTE}`))
|
|
591
|
+
console.log()
|
|
592
|
+
console.log(chalk.white(' To enable it:'))
|
|
593
|
+
console.log(chalk.dim(' 1. Press ') + chalk.bold.white('J') + chalk.dim(' to open FCM Proxy V2 settings'))
|
|
594
|
+
console.log(chalk.dim(' 2. Enable ') + chalk.bold.white('Proxy mode') + chalk.dim(' and install the ') + chalk.bold.white('background service'))
|
|
595
|
+
console.log(chalk.dim(' 3. Come back and select your model again'))
|
|
596
|
+
console.log()
|
|
597
|
+
return 1
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
338
601
|
if (mode === 'claude-code') {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
602
|
+
// 📖 Claude Code needs Anthropic-compatible wire format (POST /v1/messages).
|
|
603
|
+
// 📖 Mirror free-claude-code: one auth env only (`ANTHROPIC_AUTH_TOKEN`) plus base URL.
|
|
604
|
+
const started = await ensureProxyRunning(config)
|
|
605
|
+
const { env: proxyEnv } = buildToolEnv(mode, model, config, {
|
|
606
|
+
sanitize: true,
|
|
607
|
+
includeCompatDefaults: false,
|
|
608
|
+
includeProviderEnv: false,
|
|
609
|
+
})
|
|
610
|
+
const proxyBase = `http://127.0.0.1:${started.port}`
|
|
611
|
+
const launchModelId = resolveLauncherModelId(model, true)
|
|
612
|
+
proxyEnv.ANTHROPIC_BASE_URL = proxyBase
|
|
613
|
+
proxyEnv.ANTHROPIC_AUTH_TOKEN = started.proxyToken
|
|
614
|
+
proxyEnv.ANTHROPIC_MODEL = launchModelId
|
|
615
|
+
console.log(chalk.dim(` 📖 Claude Code routed through FCM proxy on :${started.port} (Anthropic translation enabled)`))
|
|
616
|
+
return spawnCommand('claude', ['--model', launchModelId], proxyEnv)
|
|
342
617
|
}
|
|
343
618
|
|
|
344
619
|
if (mode === 'codex') {
|
|
345
|
-
|
|
346
|
-
|
|
620
|
+
const started = await ensureProxyRunning(config)
|
|
621
|
+
const { env: proxyEnv } = buildToolEnv(mode, model, config, {
|
|
622
|
+
sanitize: true,
|
|
623
|
+
includeCompatDefaults: false,
|
|
624
|
+
includeProviderEnv: false,
|
|
625
|
+
})
|
|
626
|
+
const launchModelId = resolveLauncherModelId(model, true)
|
|
627
|
+
const proxyBaseUrl = `http://127.0.0.1:${started.port}/v1`
|
|
628
|
+
proxyEnv.FCM_PROXY_API_KEY = started.proxyToken
|
|
629
|
+
console.log(chalk.dim(` 📖 Codex routed through FCM proxy on :${started.port}`))
|
|
630
|
+
return spawnCommand('codex', [...buildCodexProxyArgs(proxyBaseUrl), '--model', launchModelId], proxyEnv)
|
|
347
631
|
}
|
|
348
632
|
|
|
349
633
|
if (mode === 'gemini') {
|
|
350
|
-
|
|
351
|
-
|
|
634
|
+
const geminiSupport = inspectGeminiCliSupport()
|
|
635
|
+
if (geminiSupport.configError) {
|
|
636
|
+
console.log()
|
|
637
|
+
console.log(chalk.red(' ✖ Gemini CLI configuration is invalid, so the proxy launch is blocked before auth.'))
|
|
638
|
+
console.log(chalk.dim(` ${geminiSupport.configError.split('\n').join('\n ')}`))
|
|
639
|
+
printExperimentalProxyNote()
|
|
640
|
+
console.log(chalk.dim(' Fix ~/.gemini/settings.json, then try again.'))
|
|
641
|
+
console.log()
|
|
642
|
+
return 1
|
|
643
|
+
}
|
|
644
|
+
if (!geminiSupport.supportsProxyBaseUrl) {
|
|
645
|
+
console.log()
|
|
646
|
+
const versionLabel = geminiSupport.version ? `v${geminiSupport.version}` : 'this installed version'
|
|
647
|
+
console.log(chalk.red(` ✖ Gemini CLI ${versionLabel} is not proxy-compatible in FCM yet.`))
|
|
648
|
+
console.log(chalk.yellow(' This build does not expose the custom base-URL contract we need, so launching it through the proxy would be misleading.'))
|
|
649
|
+
printExperimentalProxyNote()
|
|
650
|
+
console.log(chalk.dim(` Expected: Gemini CLI ${GEMINI_PROXY_MIN_VERSION}+ with stable proxy base URL support.`))
|
|
651
|
+
console.log()
|
|
652
|
+
return 1
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const started = await ensureProxyRunning(config)
|
|
656
|
+
const launchModelId = resolveLauncherModelId(model, true)
|
|
657
|
+
const { env: proxyEnv } = buildToolEnv(mode, model, config, {
|
|
658
|
+
sanitize: true,
|
|
659
|
+
includeCompatDefaults: false,
|
|
660
|
+
includeProviderEnv: false,
|
|
661
|
+
})
|
|
662
|
+
proxyEnv.GEMINI_API_KEY = started.proxyToken
|
|
663
|
+
proxyEnv.GOOGLE_API_KEY = started.proxyToken
|
|
664
|
+
proxyEnv.GOOGLE_GEMINI_BASE_URL = `http://127.0.0.1:${started.port}/v1`
|
|
665
|
+
printConfigResult(meta.label, writeGeminiConfig({ ...model, modelId: launchModelId }))
|
|
666
|
+
console.log(chalk.dim(` 📖 Gemini routed through FCM proxy on :${started.port}`))
|
|
667
|
+
return spawnCommand('gemini', ['--model', launchModelId], proxyEnv)
|
|
352
668
|
}
|
|
353
669
|
|
|
354
670
|
if (mode === 'qwen') {
|
|
@@ -375,7 +691,8 @@ export async function startExternalTool(mode, model, config) {
|
|
|
375
691
|
const piResult = writePiConfig(model, apiKey, baseUrl)
|
|
376
692
|
printConfigResult(meta.label, { filePath: piResult.filePath, backupPath: piResult.backupPath })
|
|
377
693
|
printConfigResult(meta.label, { filePath: piResult.settingsFilePath, backupPath: piResult.settingsBackupPath })
|
|
378
|
-
|
|
694
|
+
// 📖 Pi supports --provider and --model flags for guaranteed auto-selection
|
|
695
|
+
return spawnCommand('pi', ['--provider', 'freeCodingModels', '--model', model.modelId, '--api-key', apiKey], env)
|
|
379
696
|
}
|
|
380
697
|
|
|
381
698
|
console.log(chalk.red(` X Unsupported external tool mode: ${mode}`))
|
package/src/utils.js
CHANGED
|
@@ -386,16 +386,16 @@ export function findBestModel(results) {
|
|
|
386
386
|
// 📖 Slices from index 2 to get user-provided arguments only.
|
|
387
387
|
//
|
|
388
388
|
// 📖 Argument types:
|
|
389
|
-
// - API key: first positional arg that
|
|
389
|
+
// - API key: first positional arg that does not look like a CLI flag (e.g., "nvapi-xxx")
|
|
390
390
|
// - Boolean flags: --best, --fiable, --opencode, --opencode-desktop, --openclaw,
|
|
391
391
|
// --aider, --crush, --goose, --claude-code, --codex, --gemini, --qwen,
|
|
392
|
-
// --openhands, --amp, --pi, --no-telemetry, --json (case-insensitive)
|
|
392
|
+
// --openhands, --amp, --pi, --no-telemetry, --json, --help/-h (case-insensitive)
|
|
393
393
|
// - Value flag: --tier <letter> (the next non-flag arg is the tier value)
|
|
394
394
|
//
|
|
395
395
|
// 📖 Returns:
|
|
396
396
|
// { apiKey, bestMode, fiableMode, openCodeMode, openCodeDesktopMode, openClawMode,
|
|
397
397
|
// aiderMode, crushMode, gooseMode, claudeCodeMode, codexMode, geminiMode,
|
|
398
|
-
// qwenMode, openHandsMode, ampMode, piMode, noTelemetry, jsonMode, tierFilter }
|
|
398
|
+
// qwenMode, openHandsMode, ampMode, piMode, noTelemetry, jsonMode, helpMode, tierFilter }
|
|
399
399
|
//
|
|
400
400
|
// 📖 Note: apiKey may be null here — the main CLI falls back to env vars and saved config.
|
|
401
401
|
export function parseArgs(argv) {
|
|
@@ -420,7 +420,7 @@ export function parseArgs(argv) {
|
|
|
420
420
|
if (profileValueIdx !== -1) skipIndices.add(profileValueIdx)
|
|
421
421
|
|
|
422
422
|
for (const [i, arg] of args.entries()) {
|
|
423
|
-
if (arg.startsWith('--')) {
|
|
423
|
+
if (arg.startsWith('--') || arg === '-h') {
|
|
424
424
|
flags.push(arg.toLowerCase())
|
|
425
425
|
} else if (skipIndices.has(i)) {
|
|
426
426
|
// 📖 Skip — this is a value for --tier or --profile, not an API key
|
|
@@ -447,6 +447,7 @@ export function parseArgs(argv) {
|
|
|
447
447
|
const noTelemetry = flags.includes('--no-telemetry')
|
|
448
448
|
const cleanProxyMode = flags.includes('--clean-proxy') || flags.includes('--proxy-clean')
|
|
449
449
|
const jsonMode = flags.includes('--json')
|
|
450
|
+
const helpMode = flags.includes('--help') || flags.includes('-h')
|
|
450
451
|
|
|
451
452
|
let tierFilter = tierValueIdx !== -1 ? args[tierValueIdx].toUpperCase() : null
|
|
452
453
|
|
|
@@ -475,6 +476,7 @@ export function parseArgs(argv) {
|
|
|
475
476
|
noTelemetry,
|
|
476
477
|
cleanProxyMode,
|
|
477
478
|
jsonMode,
|
|
479
|
+
helpMode,
|
|
478
480
|
tierFilter,
|
|
479
481
|
profileName,
|
|
480
482
|
recommendMode
|
|
@@ -707,17 +709,31 @@ export function getProxyStatusInfo(proxyStartupStatus, isProxyActive, isProxyEna
|
|
|
707
709
|
}
|
|
708
710
|
|
|
709
711
|
/**
|
|
710
|
-
* 📖 getVersionStatusInfo turns
|
|
712
|
+
* 📖 getVersionStatusInfo turns startup + manual update-check state into a compact,
|
|
711
713
|
* 📖 render-friendly footer descriptor for the main table.
|
|
712
714
|
*
|
|
713
|
-
* 📖
|
|
714
|
-
* 📖
|
|
715
|
+
* 📖 Priority:
|
|
716
|
+
* 📖 1. Manual Settings check found an update (`available`)
|
|
717
|
+
* 📖 2. Startup auto-check already found a newer npm version
|
|
718
|
+
* 📖 3. Otherwise stay quiet
|
|
719
|
+
* 📖
|
|
720
|
+
* 📖 `versionAlertsEnabled` lets the CLI suppress npm-specific warnings in dev checkouts,
|
|
721
|
+
* 📖 where telling contributors to run a global npm update would be bogus.
|
|
715
722
|
*
|
|
716
723
|
* @param {'idle'|'checking'|'available'|'up-to-date'|'error'|'installing'} updateState
|
|
717
724
|
* @param {string|null} latestVersion
|
|
725
|
+
* @param {string|null} [startupLatestVersion=null]
|
|
726
|
+
* @param {boolean} [versionAlertsEnabled=true]
|
|
718
727
|
* @returns {{ isOutdated: boolean, latestVersion: string|null }}
|
|
719
728
|
*/
|
|
720
|
-
export function getVersionStatusInfo(updateState, latestVersion) {
|
|
729
|
+
export function getVersionStatusInfo(updateState, latestVersion, startupLatestVersion = null, versionAlertsEnabled = true) {
|
|
730
|
+
if (!versionAlertsEnabled) {
|
|
731
|
+
return {
|
|
732
|
+
isOutdated: false,
|
|
733
|
+
latestVersion: null,
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
721
737
|
if (updateState === 'available' && typeof latestVersion === 'string' && latestVersion.trim()) {
|
|
722
738
|
return {
|
|
723
739
|
isOutdated: true,
|
|
@@ -725,6 +741,13 @@ export function getVersionStatusInfo(updateState, latestVersion) {
|
|
|
725
741
|
}
|
|
726
742
|
}
|
|
727
743
|
|
|
744
|
+
if (typeof startupLatestVersion === 'string' && startupLatestVersion.trim()) {
|
|
745
|
+
return {
|
|
746
|
+
isOutdated: true,
|
|
747
|
+
latestVersion: startupLatestVersion.trim(),
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
728
751
|
return {
|
|
729
752
|
isOutdated: false,
|
|
730
753
|
latestVersion: null,
|