free-coding-models 0.3.11 → 0.3.12
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 +19 -0
- package/README.md +112 -1134
- package/bin/free-coding-models.js +13 -167
- package/package.json +2 -3
- package/src/cli-help.js +0 -18
- package/src/config.js +5 -117
- package/src/endpoint-installer.js +26 -64
- package/src/key-handler.js +56 -437
- package/src/legacy-proxy-cleanup.js +432 -0
- package/src/openclaw.js +69 -108
- package/src/opencode-config.js +48 -0
- package/src/opencode.js +6 -248
- package/src/overlays.js +23 -517
- package/src/product-flags.js +14 -0
- package/src/render-helpers.js +2 -34
- package/src/render-table.js +10 -18
- package/src/testfcm.js +90 -43
- package/src/token-usage-reader.js +9 -38
- package/src/tool-launchers.js +235 -409
- package/src/tool-metadata.js +0 -7
- package/src/utils.js +3 -68
- package/bin/fcm-proxy-daemon.js +0 -242
- package/src/account-manager.js +0 -634
- package/src/anthropic-translator.js +0 -440
- package/src/daemon-manager.js +0 -527
- package/src/error-classifier.js +0 -157
- package/src/log-reader.js +0 -195
- package/src/opencode-sync.js +0 -200
- package/src/proxy-foreground.js +0 -234
- package/src/proxy-server.js +0 -1506
- package/src/proxy-sync.js +0 -591
- package/src/proxy-topology.js +0 -85
- package/src/request-transformer.js +0 -180
- package/src/responses-translator.js +0 -423
- package/src/token-stats.js +0 -320
package/src/log-reader.js
DELETED
|
@@ -1,195 +0,0 @@
|
|
|
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
|
-
* requestedModel: string // public proxy model originally requested by the client
|
|
22
|
-
* provider: string // e.g. "nvidia"
|
|
23
|
-
* status: string // e.g. "200" | "429" | "error"
|
|
24
|
-
* tokens: number // promptTokens + completionTokens (0 if unknown)
|
|
25
|
-
* latency: number // ms (0 if unknown)
|
|
26
|
-
* switched: boolean // true when the router retried on a fallback provider/model
|
|
27
|
-
* switchReason: string|null // short reason label shown in the log UI
|
|
28
|
-
* }
|
|
29
|
-
*
|
|
30
|
-
* @exports loadRecentLogs
|
|
31
|
-
* @exports parseLogLine
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
import { existsSync, statSync, openSync, readSync, closeSync } from 'node:fs'
|
|
35
|
-
import { join } from 'node:path'
|
|
36
|
-
import { homedir } from 'node:os'
|
|
37
|
-
|
|
38
|
-
const DEFAULT_LOG_FILE = join(homedir(), '.free-coding-models', 'request-log.jsonl')
|
|
39
|
-
|
|
40
|
-
/** Maximum bytes to read from the tail of the file to avoid OOM on large logs. */
|
|
41
|
-
const MAX_READ_BYTES = 128 * 1024 // 128 KB
|
|
42
|
-
|
|
43
|
-
function normalizeTimestamp(raw) {
|
|
44
|
-
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
|
45
|
-
return new Date(raw).toISOString()
|
|
46
|
-
}
|
|
47
|
-
if (typeof raw === 'string') {
|
|
48
|
-
const numeric = Number(raw)
|
|
49
|
-
if (Number.isFinite(numeric)) return new Date(numeric).toISOString()
|
|
50
|
-
const parsed = new Date(raw)
|
|
51
|
-
if (!Number.isNaN(parsed.getTime())) return parsed.toISOString()
|
|
52
|
-
}
|
|
53
|
-
return null
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function inferProvider(entry) {
|
|
57
|
-
if (entry.providerKey || entry.provider) {
|
|
58
|
-
return String(entry.providerKey ?? entry.provider)
|
|
59
|
-
}
|
|
60
|
-
if (typeof entry.accountId === 'string' && entry.accountId.includes('/')) {
|
|
61
|
-
return entry.accountId.split('/')[0] || 'unknown'
|
|
62
|
-
}
|
|
63
|
-
return 'unknown'
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function inferStatus(entry) {
|
|
67
|
-
if (entry.statusCode !== undefined || entry.status !== undefined) {
|
|
68
|
-
return String(entry.statusCode ?? entry.status)
|
|
69
|
-
}
|
|
70
|
-
if (typeof entry.success === 'boolean') {
|
|
71
|
-
return entry.success ? '200' : 'error'
|
|
72
|
-
}
|
|
73
|
-
return 'unknown'
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function inferRequestType(entry) {
|
|
77
|
-
if (entry.requestType !== undefined || entry.type !== undefined) {
|
|
78
|
-
return String(entry.requestType ?? entry.type)
|
|
79
|
-
}
|
|
80
|
-
if (typeof entry.url === 'string') {
|
|
81
|
-
if (entry.url.includes('/chat/completions')) return 'chat.completions'
|
|
82
|
-
if (entry.url.includes('/models')) return 'models'
|
|
83
|
-
}
|
|
84
|
-
return 'chat.completions'
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Parse a single JSONL line into a normalised log row object.
|
|
89
|
-
*
|
|
90
|
-
* Returns `null` for any line that is blank, not valid JSON, or missing
|
|
91
|
-
* the required `timestamp` field.
|
|
92
|
-
*
|
|
93
|
-
* @param {string} line - A single text line from the JSONL file.
|
|
94
|
-
* @returns {{ time: string, requestType: string, model: string, requestedModel: string, provider: string, status: string, tokens: number, latency: number, switched: boolean, switchReason: string|null, switchedFromProvider: string|null, switchedFromModel: string|null } | null}
|
|
95
|
-
*/
|
|
96
|
-
export function parseLogLine(line) {
|
|
97
|
-
const trimmed = line.trim()
|
|
98
|
-
if (!trimmed) return null
|
|
99
|
-
let entry
|
|
100
|
-
try {
|
|
101
|
-
entry = JSON.parse(trimmed)
|
|
102
|
-
} catch {
|
|
103
|
-
return null
|
|
104
|
-
}
|
|
105
|
-
if (!entry || typeof entry !== 'object') return null
|
|
106
|
-
if (!entry.timestamp) return null
|
|
107
|
-
|
|
108
|
-
const normalizedTime = normalizeTimestamp(entry.timestamp)
|
|
109
|
-
if (!normalizedTime) return null
|
|
110
|
-
|
|
111
|
-
const model = String(entry.modelId ?? entry.model ?? 'unknown')
|
|
112
|
-
const requestedModel = typeof entry.requestedModelId === 'string'
|
|
113
|
-
? entry.requestedModelId
|
|
114
|
-
: (typeof entry.requestedModel === 'string' ? entry.requestedModel : '')
|
|
115
|
-
const provider = inferProvider(entry)
|
|
116
|
-
const status = inferStatus(entry)
|
|
117
|
-
const requestType = inferRequestType(entry)
|
|
118
|
-
const tokens = (Number(entry.usage?.prompt_tokens ?? entry.promptTokens ?? 0) +
|
|
119
|
-
Number(entry.usage?.completion_tokens ?? entry.completionTokens ?? 0)) || 0
|
|
120
|
-
const latency = Number(entry.latencyMs ?? entry.latency ?? 0) || 0
|
|
121
|
-
const switched = entry.switched === true
|
|
122
|
-
const switchReason = typeof entry.switchReason === 'string' && entry.switchReason.trim().length > 0
|
|
123
|
-
? entry.switchReason.trim()
|
|
124
|
-
: null
|
|
125
|
-
const switchedFromProvider = typeof entry.switchedFromProviderKey === 'string' && entry.switchedFromProviderKey.trim().length > 0
|
|
126
|
-
? entry.switchedFromProviderKey.trim()
|
|
127
|
-
: null
|
|
128
|
-
const switchedFromModel = typeof entry.switchedFromModelId === 'string' && entry.switchedFromModelId.trim().length > 0
|
|
129
|
-
? entry.switchedFromModelId.trim()
|
|
130
|
-
: null
|
|
131
|
-
|
|
132
|
-
return {
|
|
133
|
-
time: normalizedTime,
|
|
134
|
-
requestType,
|
|
135
|
-
model,
|
|
136
|
-
requestedModel,
|
|
137
|
-
provider,
|
|
138
|
-
status,
|
|
139
|
-
tokens,
|
|
140
|
-
latency,
|
|
141
|
-
switched,
|
|
142
|
-
switchReason,
|
|
143
|
-
switchedFromProvider,
|
|
144
|
-
switchedFromModel,
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Load the N most-recent log entries from the JSONL file, newest-first.
|
|
150
|
-
*
|
|
151
|
-
* Only reads up to MAX_READ_BYTES from the end of the file to avoid
|
|
152
|
-
* loading the entire log history. Malformed lines are silently skipped.
|
|
153
|
-
*
|
|
154
|
-
* @param {object} [opts]
|
|
155
|
-
* @param {string} [opts.logFile] - Path to request-log.jsonl (injectable for tests)
|
|
156
|
-
* @param {number} [opts.limit] - Maximum rows to return (default 200)
|
|
157
|
-
* @returns {Array<{ time: string, requestType: string, model: string, requestedModel: string, provider: string, status: string, tokens: number, latency: number, switched: boolean, switchReason: string|null, switchedFromProvider: string|null, switchedFromModel: string|null }>}
|
|
158
|
-
*/
|
|
159
|
-
export function loadRecentLogs({ logFile = DEFAULT_LOG_FILE, limit = 200 } = {}) {
|
|
160
|
-
try {
|
|
161
|
-
if (!existsSync(logFile)) return []
|
|
162
|
-
|
|
163
|
-
const fileSize = statSync(logFile).size
|
|
164
|
-
if (fileSize === 0) return []
|
|
165
|
-
|
|
166
|
-
// 📖 Read only the tail of the file (bounded by MAX_READ_BYTES) to avoid
|
|
167
|
-
// 📖 reading multi-megabyte logs on every TUI repaint.
|
|
168
|
-
const readBytes = Math.min(fileSize, MAX_READ_BYTES)
|
|
169
|
-
const fileOffset = fileSize - readBytes
|
|
170
|
-
|
|
171
|
-
const buf = Buffer.allocUnsafe(readBytes)
|
|
172
|
-
const fd = openSync(logFile, 'r')
|
|
173
|
-
try {
|
|
174
|
-
readSync(fd, buf, 0, readBytes, fileOffset)
|
|
175
|
-
} finally {
|
|
176
|
-
closeSync(fd)
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const text = buf.toString('utf8')
|
|
180
|
-
|
|
181
|
-
// 📖 Split on newlines; if we started mid-line (fileOffset > 0), drop
|
|
182
|
-
// 📖 the first (potentially incomplete) line to avoid corrupt JSON.
|
|
183
|
-
const rawLines = text.split('\n')
|
|
184
|
-
const lines = fileOffset > 0 ? rawLines.slice(1) : rawLines
|
|
185
|
-
|
|
186
|
-
const rows = []
|
|
187
|
-
for (let i = lines.length - 1; i >= 0 && rows.length < limit; i--) {
|
|
188
|
-
const row = parseLogLine(lines[i])
|
|
189
|
-
if (row) rows.push(row)
|
|
190
|
-
}
|
|
191
|
-
return rows
|
|
192
|
-
} catch {
|
|
193
|
-
return []
|
|
194
|
-
}
|
|
195
|
-
}
|
package/src/opencode-sync.js
DELETED
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
|
-
import { homedir } from 'node:os'
|
|
4
|
-
import { randomBytes } from 'node:crypto'
|
|
5
|
-
|
|
6
|
-
const OC_CONFIG_DIR = join(homedir(), '.config', 'opencode')
|
|
7
|
-
const OC_CONFIG_PATH = join(OC_CONFIG_DIR, 'opencode.json')
|
|
8
|
-
const OC_BACKUP_PATH = join(OC_CONFIG_DIR, 'opencode.json.bak')
|
|
9
|
-
const FCM_PROVIDER_ID = 'fcm-proxy'
|
|
10
|
-
const DEFAULT_PROXY_BASE_URL = 'http://127.0.0.1:8045/v1'
|
|
11
|
-
|
|
12
|
-
function generateProxyToken() {
|
|
13
|
-
return `fcm_${randomBytes(24).toString('hex')}`
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function ensureV1BaseUrl(baseURL) {
|
|
17
|
-
if (typeof baseURL !== 'string' || baseURL.length === 0) {
|
|
18
|
-
return DEFAULT_PROXY_BASE_URL
|
|
19
|
-
}
|
|
20
|
-
const trimmed = baseURL.replace(/\/+$/, '')
|
|
21
|
-
return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Load existing OpenCode config, or return empty object.
|
|
26
|
-
*/
|
|
27
|
-
export function loadOpenCodeConfig() {
|
|
28
|
-
try {
|
|
29
|
-
if (existsSync(OC_CONFIG_PATH)) {
|
|
30
|
-
return JSON.parse(readFileSync(OC_CONFIG_PATH, 'utf8'))
|
|
31
|
-
}
|
|
32
|
-
} catch {}
|
|
33
|
-
return {}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Save OpenCode config with automatic backup.
|
|
38
|
-
* Creates backup of current config before overwriting.
|
|
39
|
-
*/
|
|
40
|
-
export function saveOpenCodeConfig(config) {
|
|
41
|
-
mkdirSync(OC_CONFIG_DIR, { recursive: true })
|
|
42
|
-
// Backup existing config before saving
|
|
43
|
-
if (existsSync(OC_CONFIG_PATH)) {
|
|
44
|
-
copyFileSync(OC_CONFIG_PATH, OC_BACKUP_PATH)
|
|
45
|
-
}
|
|
46
|
-
writeFileSync(OC_CONFIG_PATH, JSON.stringify(config, null, 2) + '\n')
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Restore OpenCode config from backup.
|
|
51
|
-
* @returns {boolean} true if restored, false if no backup exists
|
|
52
|
-
*/
|
|
53
|
-
export function restoreOpenCodeBackup() {
|
|
54
|
-
if (!existsSync(OC_BACKUP_PATH)) return false
|
|
55
|
-
copyFileSync(OC_BACKUP_PATH, OC_CONFIG_PATH)
|
|
56
|
-
return true
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Pure merge: apply FCM provider entry into an existing OpenCode config object.
|
|
61
|
-
*
|
|
62
|
-
* This function contains the merge logic without any filesystem I/O so it can
|
|
63
|
-
* be unit-tested in isolation. It is exported for tests and used internally by
|
|
64
|
-
* syncToOpenCode.
|
|
65
|
-
*
|
|
66
|
-
* CRITICAL: This function ONLY adds/updates the fcm-proxy provider entry.
|
|
67
|
-
* It PRESERVES all existing providers (antigravity-manager, openai, iflow, etc.)
|
|
68
|
-
* and all other top-level keys ($schema, mcp, plugin, command, model).
|
|
69
|
-
*
|
|
70
|
-
* proxyInfo should only carry runtime port/token when the proxy is actively
|
|
71
|
-
* running (running === true). Callers MUST NOT pass stale values from a stopped
|
|
72
|
-
* proxy — use undefined/omit the fields instead so we fall back to the existing
|
|
73
|
-
* persisted provider options cleanly.
|
|
74
|
-
*
|
|
75
|
-
* @param {Object} ocConfig - Existing OpenCode config object (will be mutated in-place)
|
|
76
|
-
* @param {Array} mergedModels - Output of buildMergedModels()
|
|
77
|
-
* @param {{ proxyPort?: number, proxyToken?: string, availableModelSlugs?: Set<string>|string[] }} proxyInfo
|
|
78
|
-
* availableModelSlugs: when provided, only models whose slug is in this set are written
|
|
79
|
-
* to the OpenCode catalog. Use this to prevent "ghost" entries for models with no API keys.
|
|
80
|
-
* @returns {Object} The mutated ocConfig
|
|
81
|
-
*/
|
|
82
|
-
export function mergeOcConfig(ocConfig, mergedModels, proxyInfo = {}) {
|
|
83
|
-
ocConfig.provider = ocConfig.provider || {}
|
|
84
|
-
|
|
85
|
-
const existingProvider = ocConfig.provider[FCM_PROVIDER_ID] || {}
|
|
86
|
-
const existingOptions = existingProvider.options || {}
|
|
87
|
-
|
|
88
|
-
// Only use the runtime proxyPort if it is a valid positive integer.
|
|
89
|
-
// A null/undefined/0 port means the proxy is not running — fall back to
|
|
90
|
-
// the existing persisted baseURL so we don't write a broken URL.
|
|
91
|
-
const hasValidPort = Number.isInteger(proxyInfo.proxyPort) && proxyInfo.proxyPort > 0
|
|
92
|
-
const baseURL = ensureV1BaseUrl(
|
|
93
|
-
hasValidPort
|
|
94
|
-
? `http://127.0.0.1:${proxyInfo.proxyPort}`
|
|
95
|
-
: existingOptions.baseURL
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
// Keep token stable unless caller provides a runtime token.
|
|
99
|
-
// A non-string or empty proxyToken is treated as absent.
|
|
100
|
-
const hasValidToken = typeof proxyInfo.proxyToken === 'string' && proxyInfo.proxyToken.length > 0
|
|
101
|
-
const hasExistingToken =
|
|
102
|
-
typeof existingOptions.apiKey === 'string' &&
|
|
103
|
-
existingOptions.apiKey.length > 0 &&
|
|
104
|
-
existingOptions.apiKey !== 'fcm-proxy-token'
|
|
105
|
-
const apiKey = hasValidToken ? proxyInfo.proxyToken : (hasExistingToken ? existingOptions.apiKey : generateProxyToken())
|
|
106
|
-
|
|
107
|
-
const slugFilter = proxyInfo.availableModelSlugs
|
|
108
|
-
? new Set(proxyInfo.availableModelSlugs)
|
|
109
|
-
: null
|
|
110
|
-
|
|
111
|
-
const models = {}
|
|
112
|
-
for (const m of mergedModels) {
|
|
113
|
-
if (slugFilter && !slugFilter.has(m.slug)) continue
|
|
114
|
-
models[m.slug] = { name: m.label }
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
ocConfig.provider[FCM_PROVIDER_ID] = {
|
|
118
|
-
npm: '@ai-sdk/openai-compatible',
|
|
119
|
-
name: 'FCM Rotation Proxy',
|
|
120
|
-
options: {
|
|
121
|
-
...existingOptions,
|
|
122
|
-
baseURL,
|
|
123
|
-
apiKey,
|
|
124
|
-
},
|
|
125
|
-
models,
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return ocConfig
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Pure cleanup: remove only the persisted FCM proxy provider and any default
|
|
133
|
-
* model that still points at it. Other OpenCode providers stay untouched.
|
|
134
|
-
*
|
|
135
|
-
* @param {Object} ocConfig - Existing OpenCode config object (will be mutated in-place)
|
|
136
|
-
* @returns {{ removedProvider: boolean, removedModel: boolean, config: Object }}
|
|
137
|
-
*/
|
|
138
|
-
export function removeFcmProxyFromConfig(ocConfig) {
|
|
139
|
-
if (!ocConfig || typeof ocConfig !== 'object') {
|
|
140
|
-
return { removedProvider: false, removedModel: false, config: {} }
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const hadProvider = Boolean(ocConfig.provider?.[FCM_PROVIDER_ID])
|
|
144
|
-
if (hadProvider) {
|
|
145
|
-
delete ocConfig.provider[FCM_PROVIDER_ID]
|
|
146
|
-
if (Object.keys(ocConfig.provider).length === 0) delete ocConfig.provider
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const hadModel = typeof ocConfig.model === 'string' && ocConfig.model.startsWith(`${FCM_PROVIDER_ID}/`)
|
|
150
|
-
if (hadModel) delete ocConfig.model
|
|
151
|
-
|
|
152
|
-
return { removedProvider: hadProvider, removedModel: hadModel, config: ocConfig }
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* MERGE the single FCM proxy provider into OpenCode config.
|
|
157
|
-
*
|
|
158
|
-
* CRITICAL: This function ONLY adds/updates the fcm-proxy provider entry.
|
|
159
|
-
* It PRESERVES all existing providers (antigravity-manager, openai, iflow, etc.)
|
|
160
|
-
* and all other top-level keys ($schema, mcp, plugin, command, model).
|
|
161
|
-
*
|
|
162
|
-
* proxyInfo should only carry runtime port/token when the proxy is actively
|
|
163
|
-
* running (running === true). Callers MUST NOT pass stale values from a stopped
|
|
164
|
-
* proxy — use undefined/omit the fields instead so we fall back to the existing
|
|
165
|
-
* persisted provider options cleanly.
|
|
166
|
-
*
|
|
167
|
-
* @param {Object} fcmConfig - FCM config (from loadConfig())
|
|
168
|
-
* @param {Object} _sources - PROVIDERS object from sources.js (unused, kept for signature compatibility)
|
|
169
|
-
* @param {Array} mergedModels - Output of buildMergedModels()
|
|
170
|
-
* @param {{ proxyPort?: number, proxyToken?: string, availableModelSlugs?: Set<string>|string[] }} proxyInfo
|
|
171
|
-
* availableModelSlugs: slugs of models that have real API key accounts. When provided,
|
|
172
|
-
* only those models appear in the OpenCode catalog, preventing ghost entries.
|
|
173
|
-
*/
|
|
174
|
-
export function syncToOpenCode(fcmConfig, _sources, mergedModels, proxyInfo = {}) {
|
|
175
|
-
const oc = loadOpenCodeConfig()
|
|
176
|
-
const merged = mergeOcConfig(oc, mergedModels, proxyInfo)
|
|
177
|
-
saveOpenCodeConfig(merged)
|
|
178
|
-
return {
|
|
179
|
-
providerKey: FCM_PROVIDER_ID,
|
|
180
|
-
modelCount: Object.keys(merged.provider[FCM_PROVIDER_ID].models).length,
|
|
181
|
-
path: OC_CONFIG_PATH,
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Remove the persisted FCM proxy provider from OpenCode's config on disk.
|
|
187
|
-
* This is the user-facing cleanup operation for "proxy uninstall".
|
|
188
|
-
*
|
|
189
|
-
* @returns {{ removedProvider: boolean, removedModel: boolean, path: string }}
|
|
190
|
-
*/
|
|
191
|
-
export function cleanupOpenCodeProxyConfig() {
|
|
192
|
-
const oc = loadOpenCodeConfig()
|
|
193
|
-
const result = removeFcmProxyFromConfig(oc)
|
|
194
|
-
saveOpenCodeConfig(result.config)
|
|
195
|
-
return {
|
|
196
|
-
removedProvider: result.removedProvider,
|
|
197
|
-
removedModel: result.removedModel,
|
|
198
|
-
path: OC_CONFIG_PATH,
|
|
199
|
-
}
|
|
200
|
-
}
|
package/src/proxy-foreground.js
DELETED
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file src/proxy-foreground.js
|
|
3
|
-
* @description Foreground proxy mode — starts the FCM Proxy V2 in the current terminal
|
|
4
|
-
* with a live dashboard showing status, accounts, and incoming requests.
|
|
5
|
-
*
|
|
6
|
-
* 📖 This is the `--proxy` flag handler. Unlike the daemon, it runs in the foreground
|
|
7
|
-
* with a live-updating terminal UI that shows proxy health and request activity.
|
|
8
|
-
* Perfect for debugging, dev testing (no .git check), and monitoring.
|
|
9
|
-
*
|
|
10
|
-
* @functions
|
|
11
|
-
* → startForegroundProxy(config, chalk) — main entry point, starts proxy + dashboard
|
|
12
|
-
*
|
|
13
|
-
* @exports startForegroundProxy
|
|
14
|
-
*
|
|
15
|
-
* @see src/proxy-server.js — ProxyServer implementation
|
|
16
|
-
* @see src/proxy-topology.js — topology builder
|
|
17
|
-
* @see bin/fcm-proxy-daemon.js — headless daemon equivalent
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { loadConfig, getProxySettings } from './config.js'
|
|
21
|
-
import { ProxyServer } from './proxy-server.js'
|
|
22
|
-
import { buildProxyTopologyFromConfig, buildMergedModelsForDaemon } from './proxy-topology.js'
|
|
23
|
-
import { sources } from '../sources.js'
|
|
24
|
-
import { syncProxyToTool, resolveProxySyncToolMode } from './proxy-sync.js'
|
|
25
|
-
import { buildMergedModels } from './model-merger.js'
|
|
26
|
-
import { createHash, randomBytes } from 'node:crypto'
|
|
27
|
-
|
|
28
|
-
// 📖 Default foreground proxy port — same as daemon
|
|
29
|
-
const DEFAULT_PORT = 18045
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* 📖 Start the proxy in foreground mode with a live terminal dashboard.
|
|
33
|
-
* 📖 No .git check, no daemon install — just starts the proxy and shows activity.
|
|
34
|
-
*
|
|
35
|
-
* @param {object} config — loaded FCM config
|
|
36
|
-
* @param {object} chalk — chalk instance for terminal colors
|
|
37
|
-
*/
|
|
38
|
-
export async function startForegroundProxy(config, chalk) {
|
|
39
|
-
const proxySettings = getProxySettings(config)
|
|
40
|
-
const port = proxySettings.preferredPort || DEFAULT_PORT
|
|
41
|
-
|
|
42
|
-
// 📖 Ensure a stable token exists — generate one if missing (dev-friendly)
|
|
43
|
-
let token = proxySettings.stableToken
|
|
44
|
-
if (!token) {
|
|
45
|
-
token = 'fcm_' + randomBytes(16).toString('hex')
|
|
46
|
-
console.log(chalk.yellow(' ⚠ No stableToken in config — generated a temporary one for this session'))
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
console.log()
|
|
50
|
-
console.log(chalk.bold(' 📡 FCM Proxy V2 — Foreground Mode'))
|
|
51
|
-
console.log(chalk.dim(' ─────────────────────────────────────────────'))
|
|
52
|
-
console.log()
|
|
53
|
-
|
|
54
|
-
// 📖 Build topology
|
|
55
|
-
console.log(chalk.dim(' Building merged model catalog...'))
|
|
56
|
-
let mergedModels
|
|
57
|
-
try {
|
|
58
|
-
mergedModels = await buildMergedModelsForDaemon()
|
|
59
|
-
} catch (err) {
|
|
60
|
-
console.error(chalk.red(` ✗ Failed to build model catalog: ${err.message}`))
|
|
61
|
-
process.exit(1)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const topology = buildProxyTopologyFromConfig(config, mergedModels, sources)
|
|
65
|
-
const { accounts, proxyModels, anthropicRouting } = topology
|
|
66
|
-
|
|
67
|
-
if (accounts.length === 0) {
|
|
68
|
-
console.error(chalk.red(' ✗ No API keys configured — no accounts to serve.'))
|
|
69
|
-
console.error(chalk.dim(' Add keys via the TUI first (run free-coding-models without --proxy)'))
|
|
70
|
-
process.exit(1)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// 📖 Start proxy server
|
|
74
|
-
const proxy = new ProxyServer({
|
|
75
|
-
port,
|
|
76
|
-
accounts,
|
|
77
|
-
proxyApiKey: token,
|
|
78
|
-
anthropicRouting,
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
let listeningPort
|
|
82
|
-
try {
|
|
83
|
-
const result = await proxy.start()
|
|
84
|
-
listeningPort = result.port
|
|
85
|
-
} catch (err) {
|
|
86
|
-
if (err.code === 'EADDRINUSE') {
|
|
87
|
-
console.error(chalk.red(` ✗ Port ${port} already in use.`))
|
|
88
|
-
console.error(chalk.dim(' Another FCM proxy or process may be running on that port.'))
|
|
89
|
-
process.exit(2)
|
|
90
|
-
}
|
|
91
|
-
console.error(chalk.red(` ✗ Failed to start proxy: ${err.message}`))
|
|
92
|
-
process.exit(1)
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const modelCount = Object.keys(proxyModels).length
|
|
96
|
-
|
|
97
|
-
// 📖 Sync env file for claude-code if it's a syncable tool
|
|
98
|
-
try {
|
|
99
|
-
const baseUrl = `http://127.0.0.1:${listeningPort}/v1`
|
|
100
|
-
const proxyInfo = { baseUrl, token }
|
|
101
|
-
syncProxyToTool('claude-code', proxyInfo, mergedModels)
|
|
102
|
-
} catch { /* best effort */ }
|
|
103
|
-
|
|
104
|
-
// 📖 Dashboard header
|
|
105
|
-
console.log(chalk.green(' ✓ Proxy running'))
|
|
106
|
-
console.log()
|
|
107
|
-
console.log(chalk.bold(' Status'))
|
|
108
|
-
console.log(chalk.dim(' ─────────────────────────────────────────────'))
|
|
109
|
-
console.log(` ${chalk.cyan('Endpoint')} http://127.0.0.1:${listeningPort}`)
|
|
110
|
-
console.log(` ${chalk.cyan('Token')} ${token.slice(0, 12)}...${token.slice(-4)}`)
|
|
111
|
-
console.log(` ${chalk.cyan('Accounts')} ${accounts.length}`)
|
|
112
|
-
console.log(` ${chalk.cyan('Models')} ${modelCount}`)
|
|
113
|
-
console.log()
|
|
114
|
-
|
|
115
|
-
// 📖 Show provider breakdown
|
|
116
|
-
const byProvider = {}
|
|
117
|
-
for (const acct of accounts) {
|
|
118
|
-
byProvider[acct.providerKey] = (byProvider[acct.providerKey] || 0) + 1
|
|
119
|
-
}
|
|
120
|
-
console.log(chalk.bold(' Providers'))
|
|
121
|
-
console.log(chalk.dim(' ─────────────────────────────────────────────'))
|
|
122
|
-
for (const [provider, count] of Object.entries(byProvider).sort((a, b) => b[1] - a[1])) {
|
|
123
|
-
console.log(` ${chalk.cyan(provider.padEnd(20))} ${count} account${count > 1 ? 's' : ''}`)
|
|
124
|
-
}
|
|
125
|
-
console.log()
|
|
126
|
-
|
|
127
|
-
// 📖 Claude Code quick-start hint
|
|
128
|
-
console.log(chalk.bold(' Quick Start'))
|
|
129
|
-
console.log(chalk.dim(' ─────────────────────────────────────────────'))
|
|
130
|
-
console.log(chalk.dim(' Claude Code:'))
|
|
131
|
-
console.log(` ${chalk.cyan(`ANTHROPIC_BASE_URL=http://127.0.0.1:${listeningPort} ANTHROPIC_API_KEY=${token} claude`)}`)
|
|
132
|
-
console.log()
|
|
133
|
-
console.log(chalk.dim(' curl test:'))
|
|
134
|
-
console.log(` ${chalk.cyan(`curl -s -H "x-api-key: ${token}" http://127.0.0.1:${listeningPort}/v1/models | head`)}`)
|
|
135
|
-
console.log()
|
|
136
|
-
|
|
137
|
-
console.log(chalk.bold(' Live Requests'))
|
|
138
|
-
console.log(chalk.dim(' ─────────────────────────────────────────────'))
|
|
139
|
-
console.log(chalk.dim(' Waiting for incoming requests... (Ctrl+C to stop)'))
|
|
140
|
-
console.log()
|
|
141
|
-
|
|
142
|
-
// 📖 Monkey-patch tokenStats.record to intercept and display live requests
|
|
143
|
-
const originalRecord = proxy._tokenStats.record.bind(proxy._tokenStats)
|
|
144
|
-
let requestCount = 0
|
|
145
|
-
proxy._tokenStats.record = (entry) => {
|
|
146
|
-
originalRecord(entry)
|
|
147
|
-
requestCount++
|
|
148
|
-
|
|
149
|
-
const now = new Date().toLocaleTimeString()
|
|
150
|
-
const status = entry.success ? chalk.green(`${entry.statusCode}`) : chalk.red(`${entry.statusCode}`)
|
|
151
|
-
const latency = entry.latencyMs ? chalk.dim(`${entry.latencyMs}ms`) : ''
|
|
152
|
-
const tokens = (entry.promptTokens + entry.completionTokens) > 0
|
|
153
|
-
? chalk.dim(`${entry.promptTokens}+${entry.completionTokens}tok`)
|
|
154
|
-
: ''
|
|
155
|
-
const reqType = entry.requestType || 'unknown'
|
|
156
|
-
const model = entry.requestedModelId || entry.modelId || '?'
|
|
157
|
-
const provider = entry.providerKey || '?'
|
|
158
|
-
const switched = entry.switched ? chalk.yellow(' ↻') : ''
|
|
159
|
-
|
|
160
|
-
console.log(
|
|
161
|
-
` ${chalk.dim(now)} ${status} ${chalk.cyan(reqType.padEnd(20))} ` +
|
|
162
|
-
`${chalk.white(model)} → ${chalk.dim(provider)}${switched} ${latency} ${tokens}`
|
|
163
|
-
)
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// 📖 Also intercept errors on the _handleRequest level to show auth failures etc.
|
|
167
|
-
const originalHandleRequest = proxy._handleRequest.bind(proxy)
|
|
168
|
-
proxy._handleRequest = (req, res) => {
|
|
169
|
-
const origEnd = res.end.bind(res)
|
|
170
|
-
const method = req.method
|
|
171
|
-
const url = req.url
|
|
172
|
-
let logged = false
|
|
173
|
-
|
|
174
|
-
res.end = function (...args) {
|
|
175
|
-
if (!logged && res.statusCode >= 400) {
|
|
176
|
-
logged = true
|
|
177
|
-
const now = new Date().toLocaleTimeString()
|
|
178
|
-
const status = chalk.red(`${res.statusCode}`)
|
|
179
|
-
const ua = req.headers['user-agent'] || ''
|
|
180
|
-
// 📖 Try to detect the client tool from user-agent
|
|
181
|
-
const tool = detectClientTool(ua, req.headers)
|
|
182
|
-
const toolLabel = tool ? chalk.magenta(` [${tool}]`) : ''
|
|
183
|
-
console.log(
|
|
184
|
-
` ${chalk.dim(now)} ${status} ${chalk.cyan(`${method} ${url}`.padEnd(20))} ` +
|
|
185
|
-
`${chalk.dim('rejected')}${toolLabel}`
|
|
186
|
-
)
|
|
187
|
-
// 📖 Debug: show auth headers on 401 to help diagnose auth issues
|
|
188
|
-
if (res.statusCode === 401) {
|
|
189
|
-
const authHeader = req.headers.authorization ? `Bearer ${req.headers.authorization.slice(0, 20)}...` : 'none'
|
|
190
|
-
const xApiKeyHeader = req.headers['x-api-key'] ? `${req.headers['x-api-key'].slice(0, 20)}...` : 'none'
|
|
191
|
-
console.log(chalk.dim(` auth: ${authHeader} | x-api-key: ${xApiKeyHeader}`))
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
return origEnd(...args)
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
return originalHandleRequest(req, res)
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// 📖 Graceful shutdown
|
|
201
|
-
const shutdown = async (signal) => {
|
|
202
|
-
console.log()
|
|
203
|
-
console.log(chalk.dim(` Received ${signal} — shutting down...`))
|
|
204
|
-
try { await proxy.stop() } catch { /* best effort */ }
|
|
205
|
-
console.log(chalk.green(` ✓ Proxy stopped. ${requestCount} request${requestCount !== 1 ? 's' : ''} served this session.`))
|
|
206
|
-
console.log()
|
|
207
|
-
process.exit(0)
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
211
|
-
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* 📖 Detect which client tool sent the request based on User-Agent or custom headers.
|
|
216
|
-
* 📖 Claude Code, Codex, OpenCode etc. each have distinctive UA patterns.
|
|
217
|
-
*/
|
|
218
|
-
function detectClientTool(ua, headers) {
|
|
219
|
-
if (!ua && !headers) return null
|
|
220
|
-
const uaLower = (ua || '').toLowerCase()
|
|
221
|
-
|
|
222
|
-
if (uaLower.includes('claude') || uaLower.includes('anthropic')) return 'Claude Code'
|
|
223
|
-
if (headers?.['anthropic-version'] || headers?.['x-api-key']) return 'Anthropic SDK'
|
|
224
|
-
if (uaLower.includes('codex')) return 'Codex'
|
|
225
|
-
if (uaLower.includes('opencode')) return 'OpenCode'
|
|
226
|
-
if (uaLower.includes('cursor')) return 'Cursor'
|
|
227
|
-
if (uaLower.includes('aider')) return 'Aider'
|
|
228
|
-
if (uaLower.includes('goose')) return 'Goose'
|
|
229
|
-
if (uaLower.includes('openclaw')) return 'OpenClaw'
|
|
230
|
-
if (uaLower.includes('node-fetch') || uaLower.includes('undici')) return 'Node.js'
|
|
231
|
-
if (uaLower.includes('python')) return 'Python'
|
|
232
|
-
if (uaLower.includes('curl')) return 'curl'
|
|
233
|
-
return null
|
|
234
|
-
}
|