free-coding-models 0.5.32 → 0.5.35
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/LICENSE +43 -21
- package/README.md +2 -1
- package/changelog/v0.5.33.md +4 -0
- package/changelog/v0.5.34.md +4 -0
- package/changelog/v0.5.35.md +4 -0
- package/package.json +3 -2
- package/sources.js +12 -52
- package/src/core/router-daemon.js +56 -49
- package/src/core/schema-normalizer.js +172 -0
- package/src/core/tool-bootstrap.js +11 -0
- package/src/core/tool-launchers.js +37 -2
- package/src/core/tool-metadata.js +3 -0
- package/web/dist/assets/index-B5tTa7_O.js +40 -0
- package/web/dist/assets/index-r8niexgJ.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/src/App.jsx +93 -105
- package/web/src/components/changelog/ChangelogView.module.css +13 -14
- package/web/src/components/dashboard/FilterBar.jsx +17 -2
- package/web/src/components/dashboard/FilterBar.module.css +68 -21
- package/web/src/components/dashboard/ModelTable.jsx +118 -42
- package/web/src/components/dashboard/ModelTable.module.css +82 -27
- package/web/src/components/help/HelpView.module.css +13 -14
- package/web/src/components/install/InstallEndpointsView.module.css +13 -13
- package/web/src/components/installed/InstalledModelsView.module.css +13 -13
- package/web/src/components/layout/Header.jsx +28 -28
- package/web/src/components/layout/Header.module.css +56 -30
- package/web/src/components/palette/CommandPalette.jsx +14 -14
- package/web/src/components/playground/PlaygroundView.module.css +13 -14
- package/web/src/components/recommend/RecommendView.module.css +13 -11
- package/web/src/components/router/RouterView.module.css +13 -13
- package/web/src/components/tools/ToolPicker.module.css +27 -14
- package/web/src/global.css +58 -31
- package/web/src/hooks/urlState.constants.js +1 -1
- package/web/src/hooks/useUrlState.js +19 -3
- package/web/dist/assets/index-C_tCF0A5.js +0 -40
- package/web/dist/assets/index-CsFt3qt5.css +0 -1
|
@@ -13,20 +13,20 @@
|
|
|
13
13
|
* never written to logs or telemetry.
|
|
14
14
|
*
|
|
15
15
|
* @functions
|
|
16
|
-
* → runRouterDaemon()
|
|
17
|
-
* → startRouterDaemonBackground()
|
|
18
|
-
* → stopRouterDaemon()
|
|
19
|
-
* → getRouterDaemonStatus()
|
|
20
|
-
* → buildDefaultRouterSet()
|
|
21
|
-
* → formatOpenAiError()
|
|
22
|
-
* → createRouterRuntimeForTest()
|
|
16
|
+
* → runRouterDaemon() - Start the foreground daemon HTTP server
|
|
17
|
+
* → startRouterDaemonBackground() - Spawn the daemon detached from the TUI
|
|
18
|
+
* → stopRouterDaemon() - Send SIGTERM to the recorded daemon process
|
|
19
|
+
* → getRouterDaemonStatus() - Discover and read `/health` from a running daemon
|
|
20
|
+
* → buildDefaultRouterSet() - Create the first priority-ordered model set
|
|
21
|
+
* → formatOpenAiError() - Build OpenAI-compatible error response payloads
|
|
22
|
+
* → createRouterRuntimeForTest() - Build an isolated runtime for mock-upstream tests
|
|
23
23
|
*
|
|
24
24
|
* @exports runRouterDaemon, startRouterDaemonBackground, stopRouterDaemon
|
|
25
25
|
* @exports getRouterDaemonStatus, buildDefaultRouterSet, formatOpenAiError
|
|
26
26
|
* @exports createRouterRuntimeForTest
|
|
27
27
|
*
|
|
28
|
-
* @see ./config.js
|
|
29
|
-
* @see ../sources.js
|
|
28
|
+
* @see ./config.js - router config is persisted under `router`
|
|
29
|
+
* @see ../sources.js - provider URLs and model IDs are resolved from the catalog
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import { createServer } from 'node:http'
|
|
@@ -53,6 +53,7 @@ import { loadChangelog } from './changelog-loader.js'
|
|
|
53
53
|
import { sendUsageTelemetry } from './telemetry.js'
|
|
54
54
|
import { TIER_ORDER } from './utils.js'
|
|
55
55
|
import { atomicWriteJson, safeJsonParse, sleep, maskApiKey, isRouteableProvider } from './shared-helpers.js'
|
|
56
|
+
import { normalizeRequestBody } from './schema-normalizer.js'
|
|
56
57
|
|
|
57
58
|
export const ROUTER_DEFAULT_PORT = 19280
|
|
58
59
|
export const ROUTER_MAX_PORT = 19289
|
|
@@ -64,7 +65,7 @@ export const ROUTER_MAX_PORT_DEV = 29289
|
|
|
64
65
|
// 📖 IMPORTANT: _isDev() is a function, not a constant, so it picks up FCM_DEV
|
|
65
66
|
// 📖 changes that happen after module load (e.g. the bin entry point setting
|
|
66
67
|
// 📖 FCM_DEV=1 on git checkouts). Constant exports for PID/PORT/LOG paths
|
|
67
|
-
// 📖 are still computed eagerly
|
|
68
|
+
// 📖 are still computed eagerly - they are only used by the daemon child process
|
|
68
69
|
// 📖 which always has FCM_DEV set before import. The TUI and dashboard use
|
|
69
70
|
// 📖 getRouterPortRange() and getRouterPidPath() for dynamic resolution.
|
|
70
71
|
function _isDev() { return typeof process.env.FCM_DEV !== 'undefined' ? !!process.env.FCM_DEV : false }
|
|
@@ -74,7 +75,7 @@ export const ROUTER_PORT_PATH = join(homedir(), `.free-coding-models-daemon${_de
|
|
|
74
75
|
export const ROUTER_LOG_PATH = join(homedir(), `.free-coding-models-daemon${_dev ? '-dev' : ''}.log`)
|
|
75
76
|
export const ROUTER_TOKENS_PATH = join(homedir(), `.free-coding-models-tokens${_dev ? '-dev' : ''}.json`)
|
|
76
77
|
|
|
77
|
-
// 📖 Dynamic path resolvers
|
|
78
|
+
// 📖 Dynamic path resolvers - used by the TUI dashboard which may have FCM_DEV
|
|
78
79
|
// 📖 set after module load time (git checkout auto-detection in bin/ entry).
|
|
79
80
|
export function getRouterPidPath() { return join(homedir(), `.free-coding-models-daemon${_isDev() ? '-dev' : ''}.pid`) }
|
|
80
81
|
export function getRouterPortPath() { return join(homedir(), `.free-coding-models-daemon${_isDev() ? '-dev' : ''}.port`) }
|
|
@@ -124,7 +125,7 @@ function modelKey(provider, model) {
|
|
|
124
125
|
return `${provider}/${model}`
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
// 📖 parseJsonResult is still local
|
|
128
|
+
// 📖 parseJsonResult is still local - it returns {ok, value/error} which is different from safeJsonParse
|
|
128
129
|
function parseJsonResult(raw) {
|
|
129
130
|
try {
|
|
130
131
|
return { ok: true, value: JSON.parse(raw) }
|
|
@@ -215,7 +216,7 @@ function isLoopbackHostname(hostname) {
|
|
|
215
216
|
return h === 'localhost' || h === '127.0.0.1' || h === '[::1]' || h === '::1' || h.endsWith('.localhost')
|
|
216
217
|
}
|
|
217
218
|
|
|
218
|
-
// 📖 Private-network hostname check. Allows RFC 1918 IPs (10.x, 172.16
|
|
219
|
+
// 📖 Private-network hostname check. Allows RFC 1918 IPs (10.x, 172.16-31.x,
|
|
219
220
|
// 📖 192.168.x) and hostnames that end in `.local` or `.internal`. This
|
|
220
221
|
// 📖 enables Docker and LAN setups where the browser hits the FCM web UI
|
|
221
222
|
// 📖 from a different machine but still on a trusted network.
|
|
@@ -310,7 +311,7 @@ function getWebModelsPayload(runtime) {
|
|
|
310
311
|
: null
|
|
311
312
|
const recentOk = pings.filter((p) => typeof p.ms === 'number' && String(p.code) === '200').length
|
|
312
313
|
const stability = pings.length > 0 ? recentOk / pings.length : null
|
|
313
|
-
const verdict = avg === null ? '
|
|
314
|
+
const verdict = avg === null ? '-' : avg < 1000 ? 'Excellent' : avg < 2000 ? 'Good' : avg < 4000 ? 'Fair' : 'Poor'
|
|
314
315
|
const uptime = pings.length > 0 ? recentOk / pings.length : null
|
|
315
316
|
payload.push({
|
|
316
317
|
idx: payload.length + 1,
|
|
@@ -633,7 +634,7 @@ export function injectPrePrompt(messages, prePrompt) {
|
|
|
633
634
|
if (!prePrompt || prePrompt.enabled !== true) return messages
|
|
634
635
|
const text = typeof prePrompt.text === 'string' ? prePrompt.text.trim() : ''
|
|
635
636
|
if (!text) return messages
|
|
636
|
-
// 📖 Skip injection if the very first message is already an exact match
|
|
637
|
+
// 📖 Skip injection if the very first message is already an exact match -
|
|
637
638
|
// 📖 prevents duplicate system messages when the client retries a request
|
|
638
639
|
// 📖 or the Playground already sent the pre-prompt itself.
|
|
639
640
|
const first = messages[0]
|
|
@@ -944,7 +945,7 @@ class RouterRuntime {
|
|
|
944
945
|
}
|
|
945
946
|
|
|
946
947
|
/**
|
|
947
|
-
* 📖 markSetCustomized
|
|
948
|
+
* 📖 markSetCustomized - flip `router.userCustomized = true` and
|
|
948
949
|
* 📖 `router.autoHeal = false` so the user's manual edits are
|
|
949
950
|
* 📖 preserved on the next daemon start. Called from the HTTP
|
|
950
951
|
* 📖 endpoints that mutate the active set (add / remove / reorder /
|
|
@@ -1164,19 +1165,19 @@ class RouterRuntime {
|
|
|
1164
1165
|
})
|
|
1165
1166
|
}
|
|
1166
1167
|
|
|
1167
|
-
// 📖 getRoutingCandidates
|
|
1168
|
+
// 📖 getRoutingCandidates - the ordered list of models the router will try,
|
|
1168
1169
|
// 📖 in EXACT attempt order. This is the heart of routing.
|
|
1169
1170
|
// 📖
|
|
1170
1171
|
// 📖 Strategy (priority-first): the user's priority order is authoritative.
|
|
1171
1172
|
// 📖 A model ranked #1 is always tried first while it is healthy, even if a
|
|
1172
1173
|
// 📖 lower-priority model has a better health score. The health score is only
|
|
1173
|
-
// 📖 used to break ties between models that share the same priority
|
|
1174
|
+
// 📖 used to break ties between models that share the same priority - which
|
|
1174
1175
|
// 📖 happens in practice when multiple models tie because they have no probe
|
|
1175
1176
|
// 📖 data yet (cold start) or identical stats.
|
|
1176
1177
|
// 📖
|
|
1177
1178
|
// 📖 Why: before this, priority was only 20% of the score and a fast
|
|
1178
1179
|
// 📖 low-priority model could steal traffic from a deliberately higher-ranked
|
|
1179
|
-
// 📖 one (see issue #120
|
|
1180
|
+
// 📖 one (see issue #120 - GPT-OSS 120B served despite higher-priority models
|
|
1180
1181
|
// 📖 being healthy). Users set the fallback chain on purpose; routing must
|
|
1181
1182
|
// 📖 respect it.
|
|
1182
1183
|
// 📖
|
|
@@ -1200,7 +1201,7 @@ class RouterRuntime {
|
|
|
1200
1201
|
return [...closed.sort(byPriorityThenHealth), ...halfOpen.sort(byPriorityThenHealth)]
|
|
1201
1202
|
}
|
|
1202
1203
|
|
|
1203
|
-
// 📖 getRoutingOrder
|
|
1204
|
+
// 📖 getRoutingOrder - slim projection of getRoutingCandidates for the /stats
|
|
1204
1205
|
// 📖 payload and dashboards. Exposes the EXACT order the router will attempt
|
|
1205
1206
|
// 📖 on the next request, so the UI can mark the model that will serve it
|
|
1206
1207
|
// 📖 (routingOrder[0]) and label every entry as Primary vs Fallback.
|
|
@@ -1377,7 +1378,7 @@ class RouterRuntime {
|
|
|
1377
1378
|
// 📖 `running` mirrors `ok` so every consumer (Router view reads `ok`,
|
|
1378
1379
|
// 📖 Playground reads `running`) agrees on daemon state. Without this,
|
|
1379
1380
|
// 📖 the Playground showed "router offline" even when the Router card
|
|
1380
|
-
// 📖 said "Running"
|
|
1381
|
+
// 📖 said "Running" - both hit /api/router/status but read different fields.
|
|
1381
1382
|
running: true,
|
|
1382
1383
|
version: LOCAL_VERSION,
|
|
1383
1384
|
pid: process.pid,
|
|
@@ -1420,12 +1421,12 @@ class RouterRuntime {
|
|
|
1420
1421
|
...this.statusPayload(),
|
|
1421
1422
|
tokens: this.tokenTracker.summary(),
|
|
1422
1423
|
models: this.getModelHealth(activeSet),
|
|
1423
|
-
// 📖 routingOrder
|
|
1424
|
+
// 📖 routingOrder - the exact attempt order for the next request
|
|
1424
1425
|
// 📖 (priority-first among healthy models). routingOrder[0] is what will
|
|
1425
1426
|
// 📖 serve the next chat completion. Surfaced so dashboards can mark the
|
|
1426
1427
|
// 📖 "next" model and label Primary vs Fallback semantics. See issue #120.
|
|
1427
1428
|
routingOrder: this.getRoutingOrder(activeSet),
|
|
1428
|
-
// 📖 Global AI Latency probe progress
|
|
1429
|
+
// 📖 Global AI Latency probe progress - powers the Router Dashboard's
|
|
1429
1430
|
// 📖 "Probe all" button progress bar. Per-model results live on each
|
|
1430
1431
|
// 📖 entry of `models` above (isBenchmarking / benchmark).
|
|
1431
1432
|
globalBenchmark: {
|
|
@@ -1482,7 +1483,7 @@ class RouterRuntime {
|
|
|
1482
1483
|
if (response.ok) {
|
|
1483
1484
|
this.markSuccess(key)
|
|
1484
1485
|
this.recordProbeResult(key, { ok: true, latencyMs, code: response.status })
|
|
1485
|
-
this.logger.info(`Probe ok ${key}
|
|
1486
|
+
this.logger.info(`Probe ok ${key} - ${latencyMs}ms`)
|
|
1486
1487
|
} else if (AUTH_STATUS_CODES.has(response.status)) {
|
|
1487
1488
|
this.markAuthError(key, `HTTP ${response.status}`)
|
|
1488
1489
|
this.recordProbeResult(key, { ok: false, latencyMs, code: response.status })
|
|
@@ -1510,7 +1511,7 @@ class RouterRuntime {
|
|
|
1510
1511
|
}
|
|
1511
1512
|
|
|
1512
1513
|
/**
|
|
1513
|
-
* 📖 autoHealActiveSet
|
|
1514
|
+
* 📖 autoHealActiveSet - replaces broken models in the active set with
|
|
1514
1515
|
* 📖 working alternatives, so the Playground and Router Dashboard both
|
|
1515
1516
|
* 📖 start with a usable set by default. The user's manual edits are
|
|
1516
1517
|
* 📖 always respected: once `router.userCustomized` is true (set by
|
|
@@ -1538,12 +1539,12 @@ class RouterRuntime {
|
|
|
1538
1539
|
}
|
|
1539
1540
|
|
|
1540
1541
|
// 📖 Build a candidate pool from EVERY routeable model in the
|
|
1541
|
-
// 📖 catalog, not just the ones in the active set
|
|
1542
|
+
// 📖 catalog, not just the ones in the active set - we need healthy
|
|
1542
1543
|
// 📖 alternatives to swap in, and the active set's only models are
|
|
1543
1544
|
// 📖 the broken ones we're trying to replace.
|
|
1544
1545
|
const healthByKey = new Map()
|
|
1545
1546
|
const aliveByProvider = new Map()
|
|
1546
|
-
// 📖 Per-provider probe stats
|
|
1547
|
+
// 📖 Per-provider probe stats - we use these to detect "the user's
|
|
1547
1548
|
// 📖 whole <provider> is dead" (every probe has auth-errored) and
|
|
1548
1549
|
// 📖 skip that provider as a candidate for replacements.
|
|
1549
1550
|
const providerProbeStats = new Map() // provider -> { probed: n, authError: n, stale: n, alive: n }
|
|
@@ -1604,7 +1605,7 @@ class RouterRuntime {
|
|
|
1604
1605
|
|
|
1605
1606
|
// 📖 Also pick up models that are in the active set but NOT in the
|
|
1606
1607
|
// 📖 current catalog (e.g. removed from sources.js, deprecated by the
|
|
1607
|
-
// 📖 provider). They should be marked as broken and replaced too
|
|
1608
|
+
// 📖 provider). They should be marked as broken and replaced too -
|
|
1608
1609
|
// 📖 otherwise they'd stay in the set forever as silent dead weight.
|
|
1609
1610
|
for (const entry of set.models) {
|
|
1610
1611
|
const key = `${entry.provider}/${entry.model}`
|
|
@@ -1624,7 +1625,7 @@ class RouterRuntime {
|
|
|
1624
1625
|
// 📖 Decide what's broken. We heal AUTH_ERROR (key is wrong for that
|
|
1625
1626
|
// 📖 model) and STALE/TIMEOUT (upstream isn't responding). We do
|
|
1626
1627
|
// 📖 NOT heal HALF_OPEN (recovering) or OPEN (circuit breaker tripped
|
|
1627
|
-
// 📖 on a transient blip)
|
|
1628
|
+
// 📖 on a transient blip) - those should resolve on their own.
|
|
1628
1629
|
const isBroken = (key) => {
|
|
1629
1630
|
const health = healthByKey.get(key)
|
|
1630
1631
|
if (!health) return false
|
|
@@ -1790,7 +1791,7 @@ class RouterRuntime {
|
|
|
1790
1791
|
if (candidates.length === 0) {
|
|
1791
1792
|
const health = this.getModelHealth(set)
|
|
1792
1793
|
const quotaExhausted = [...this.quotaExhausted].filter((key) => set.models.some((model) => modelKey(model.provider, model.model) === key))
|
|
1793
|
-
|
|
1794
|
+
|
|
1794
1795
|
let statusCode = 503
|
|
1795
1796
|
let errorCode = 'all_models_unavailable'
|
|
1796
1797
|
let errorType = 'service_unavailable'
|
|
@@ -1919,8 +1920,11 @@ class RouterRuntime {
|
|
|
1919
1920
|
// 📖 curl, custom Playground) gets the FCM persona without any client
|
|
1920
1921
|
// 📖 change. Non-streaming path.
|
|
1921
1922
|
const bodyWithPrePrompt = applyPrePromptToBody(body, this.routerConfig().prePrompt)
|
|
1923
|
+
// 📖 Apply per-provider schema normalization (GLM, Mistral, Codestral).
|
|
1924
|
+
// 📖 Returns the body unchanged for providers without a registered normalizer.
|
|
1925
|
+
const bodyNormalized = normalizeRequestBody(bodyWithPrePrompt, candidate.provider)
|
|
1922
1926
|
const upstreamBody = {
|
|
1923
|
-
...
|
|
1927
|
+
...bodyNormalized,
|
|
1924
1928
|
model: getApiModelId(candidate.provider, candidate.model),
|
|
1925
1929
|
stream: false,
|
|
1926
1930
|
}
|
|
@@ -1928,7 +1932,7 @@ class RouterRuntime {
|
|
|
1928
1932
|
if (upstreamBody.add_generation_prompt !== undefined) delete upstreamBody.add_generation_prompt
|
|
1929
1933
|
if (upstreamBody.continue_final_message !== undefined) delete upstreamBody.continue_final_message
|
|
1930
1934
|
if (upstreamBody.tools?.length === 0) delete upstreamBody.tools
|
|
1931
|
-
|
|
1935
|
+
|
|
1932
1936
|
const clientAbort = attachClientAbort(req, res, controller)
|
|
1933
1937
|
try {
|
|
1934
1938
|
const response = await fetch(providerUrl, {
|
|
@@ -1983,7 +1987,7 @@ class RouterRuntime {
|
|
|
1983
1987
|
tokens: usage?.total_tokens || 0,
|
|
1984
1988
|
failover: attemptIndex > 0,
|
|
1985
1989
|
})
|
|
1986
|
-
this.logger.info(`Routed to ${key}
|
|
1990
|
+
this.logger.info(`Routed to ${key} - ${latencyMs}ms`, { request_id: requestId, status: response.status })
|
|
1987
1991
|
if (!res.writableEnded) {
|
|
1988
1992
|
res.writeHead(response.status, {
|
|
1989
1993
|
...headerEntries(response.headers),
|
|
@@ -2007,7 +2011,7 @@ class RouterRuntime {
|
|
|
2007
2011
|
return { done: false, failoverToNext: true, reason: `http_${response.status}` }
|
|
2008
2012
|
}
|
|
2009
2013
|
|
|
2010
|
-
// 📖 Provide failover fallback for non-retryable errors from the provider (like 400 Bad Request)
|
|
2014
|
+
// 📖 Provide failover fallback for non-retryable errors from the provider (like 400 Bad Request)
|
|
2011
2015
|
// when they are caused by format idiosyncrasies (e.g. empty tools array that another model might accept)
|
|
2012
2016
|
if (response.status >= 400 && response.status < 500) {
|
|
2013
2017
|
this.recordRouterError(`http_${response.status}`, requestId, { model: key, status: response.status, body: text })
|
|
@@ -2057,8 +2061,11 @@ class RouterRuntime {
|
|
|
2057
2061
|
// 📖 curl, custom Playground) gets the FCM persona without any client
|
|
2058
2062
|
// 📖 change. Streaming path.
|
|
2059
2063
|
const bodyWithPrePrompt = applyPrePromptToBody(body, this.routerConfig().prePrompt)
|
|
2064
|
+
// 📖 Apply per-provider schema normalization (GLM, Mistral, Codestral).
|
|
2065
|
+
// 📖 Returns the body unchanged for providers without a registered normalizer.
|
|
2066
|
+
const bodyNormalized = normalizeRequestBody(bodyWithPrePrompt, candidate.provider)
|
|
2060
2067
|
const upstreamBody = {
|
|
2061
|
-
...
|
|
2068
|
+
...bodyNormalized,
|
|
2062
2069
|
model: getApiModelId(candidate.provider, candidate.model),
|
|
2063
2070
|
stream: true,
|
|
2064
2071
|
}
|
|
@@ -2066,7 +2073,7 @@ class RouterRuntime {
|
|
|
2066
2073
|
if (upstreamBody.add_generation_prompt !== undefined) delete upstreamBody.add_generation_prompt
|
|
2067
2074
|
if (upstreamBody.continue_final_message !== undefined) delete upstreamBody.continue_final_message
|
|
2068
2075
|
if (upstreamBody.tools?.length === 0) delete upstreamBody.tools
|
|
2069
|
-
|
|
2076
|
+
|
|
2070
2077
|
const timeout = setTimeout(() => controller.abort(), this.routerConfig().failover.requestTimeoutMs)
|
|
2071
2078
|
let sentToClient = false
|
|
2072
2079
|
const clientAbort = attachClientAbort(req, res, controller)
|
|
@@ -2100,8 +2107,8 @@ class RouterRuntime {
|
|
|
2100
2107
|
this.addRequestLog({ request_id: requestId, model: key, status: response.status, latency_ms: latencyMs, tokens: 0, failover: attemptIndex > 0, error: `http_${response.status}`, stream: true })
|
|
2101
2108
|
return { done: false, failoverToNext: true, reason: `http_${response.status}` }
|
|
2102
2109
|
}
|
|
2103
|
-
|
|
2104
|
-
// 📖 Provide failover fallback for non-retryable errors from the provider (like 400 Bad Request)
|
|
2110
|
+
|
|
2111
|
+
// 📖 Provide failover fallback for non-retryable errors from the provider (like 400 Bad Request)
|
|
2105
2112
|
// when they are caused by format idiosyncrasies (e.g. empty tools array that another model might accept)
|
|
2106
2113
|
if (response.status >= 400 && response.status < 500) {
|
|
2107
2114
|
const rawErr = await response.text()
|
|
@@ -2305,7 +2312,7 @@ class RouterRuntime {
|
|
|
2305
2312
|
return
|
|
2306
2313
|
}
|
|
2307
2314
|
|
|
2308
|
-
// 📖 POST /sets/:name/models
|
|
2315
|
+
// 📖 POST /sets/:name/models - append a single model to a set. The model
|
|
2309
2316
|
// 📖 is auto-prioritized to the end of the list (priority = count+1).
|
|
2310
2317
|
// 📖 This is the granular alternative to PUT /sets/:name for clients
|
|
2311
2318
|
// 📖 that just want to add one entry without resending the full array.
|
|
@@ -2353,7 +2360,7 @@ class RouterRuntime {
|
|
|
2353
2360
|
return
|
|
2354
2361
|
}
|
|
2355
2362
|
|
|
2356
|
-
// 📖 DELETE /sets/:name/models
|
|
2363
|
+
// 📖 DELETE /sets/:name/models - remove a single model from a set.
|
|
2357
2364
|
// 📖 The body is `{ provider, model }` (using the body keeps the URL
|
|
2358
2365
|
// 📖 short and matches the POST shape).
|
|
2359
2366
|
if (setModelsMatch && req.method === 'DELETE') {
|
|
@@ -2390,7 +2397,7 @@ class RouterRuntime {
|
|
|
2390
2397
|
return
|
|
2391
2398
|
}
|
|
2392
2399
|
|
|
2393
|
-
// 📖 POST /sets/:name/reorder
|
|
2400
|
+
// 📖 POST /sets/:name/reorder - accept a full priority order from the
|
|
2394
2401
|
// 📖 client. Body shape: `{ order: ["provider/model", "provider/model"] }`.
|
|
2395
2402
|
// 📖 The daemon re-derives the canonical `{ provider, model, priority }`
|
|
2396
2403
|
// 📖 objects from the order, so the client never has to know the
|
|
@@ -2418,7 +2425,7 @@ class RouterRuntime {
|
|
|
2418
2425
|
return
|
|
2419
2426
|
}
|
|
2420
2427
|
}
|
|
2421
|
-
// 📖 Reject the request if the client omitted some keys
|
|
2428
|
+
// 📖 Reject the request if the client omitted some keys - reordering
|
|
2422
2429
|
// 📖 must be a permutation of the current set, not a partial edit.
|
|
2423
2430
|
if (order.length !== currentModels.length) {
|
|
2424
2431
|
sendError(res, 400, 'Order must include every model in the set', 'invalid_request_error', 'order_size_mismatch', requestId)
|
|
@@ -2439,7 +2446,7 @@ class RouterRuntime {
|
|
|
2439
2446
|
}
|
|
2440
2447
|
|
|
2441
2448
|
/**
|
|
2442
|
-
* 📖 POST /sets/:name/sync
|
|
2449
|
+
* 📖 POST /sets/:name/sync - re-run the probe-based sync-set pipeline
|
|
2443
2450
|
* 📖 against the named set. The pipeline probes up to `maxProbes` model
|
|
2444
2451
|
* 📖 candidates with the user's actual API keys and rebuilds the set
|
|
2445
2452
|
* 📖 with only the ones that come back 2xx. Returns the new set + a
|
|
@@ -2670,7 +2677,7 @@ class RouterRuntime {
|
|
|
2670
2677
|
return
|
|
2671
2678
|
}
|
|
2672
2679
|
// 📖 Stub endpoints for the web dashboard's hooks (useToolMode, useFavorites,
|
|
2673
|
-
// 📖 useUpdateChecker). These were 404 before
|
|
2680
|
+
// 📖 useUpdateChecker). These were 404 before - minimal shapes that match
|
|
2674
2681
|
// 📖 what the dashboard hooks expect. See PR #108 for context.
|
|
2675
2682
|
if (req.method === 'GET' && (url.pathname === '/api/tool-mode')) {
|
|
2676
2683
|
sendJson(res, 200, { mode: 'opencode', tools: ['opencode', 'openclaw', 'opencode-desktop', 'opencode-web'] }, { 'x-request-id': requestId })
|
|
@@ -2685,7 +2692,7 @@ class RouterRuntime {
|
|
|
2685
2692
|
sendJson(res, 200, { local: LOCAL_VERSION, latest: null, lastReleaseDate: null, error: null }, { 'x-request-id': requestId })
|
|
2686
2693
|
return
|
|
2687
2694
|
}
|
|
2688
|
-
// 📖 /api/router/catalog
|
|
2695
|
+
// 📖 /api/router/catalog - lightweight catalog of routeable models for
|
|
2689
2696
|
// 📖 the Web Router Dashboard's "Add model" picker. Returns one row
|
|
2690
2697
|
// 📖 per (provider, model) with `key`, label, tier, ctx. We filter to
|
|
2691
2698
|
// 📖 routeable providers only so the picker never offers a model the
|
|
@@ -2869,7 +2876,7 @@ class RouterRuntime {
|
|
|
2869
2876
|
}
|
|
2870
2877
|
}
|
|
2871
2878
|
if (req.method === 'POST' && url.pathname === '/api/settings') {
|
|
2872
|
-
// 📖 Writes API keys + provider toggles
|
|
2879
|
+
// 📖 Writes API keys + provider toggles - same-origin only to block
|
|
2873
2880
|
// 📖 CSRF-style writes from malicious browser tabs.
|
|
2874
2881
|
if (!isSameOriginOrLocal(req)) {
|
|
2875
2882
|
sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
|
|
@@ -3028,7 +3035,7 @@ class RouterRuntime {
|
|
|
3028
3035
|
}
|
|
3029
3036
|
|
|
3030
3037
|
// 📖 Pinned picks: only used as a *tie-breaker* when multiple models have
|
|
3031
|
-
// 📖 identical (tier, sweScore, latency)
|
|
3038
|
+
// 📖 identical (tier, sweScore, latency) - never a hard requirement, so
|
|
3032
3039
|
// 📖 a user whose NVIDIA key is dead still gets a working set.
|
|
3033
3040
|
const PREFERRED_DEFAULT_MODELS = [
|
|
3034
3041
|
{ provider: 'groq', model: 'llama-3.3-70b-versatile' },
|
|
@@ -3213,7 +3220,7 @@ export function createRouterRuntimeForTest({ config, port = 0, logger = null, to
|
|
|
3213
3220
|
}
|
|
3214
3221
|
|
|
3215
3222
|
/**
|
|
3216
|
-
* 📖 createDefaultProbeFn
|
|
3223
|
+
* 📖 createDefaultProbeFn - used by buildDefaultRouterSet to find models
|
|
3217
3224
|
* 📖 that actually work with the user's API keys. Returns an async probe
|
|
3218
3225
|
* 📖 `(entry) => { ok, latencyMs, code }` that posts a 1-token chat-
|
|
3219
3226
|
* 📖 completion to the provider's URL and treats 2xx as "working".
|
|
@@ -3242,7 +3249,7 @@ function createDefaultProbeFn(apiKeys) {
|
|
|
3242
3249
|
})
|
|
3243
3250
|
const headers = { 'Content-Type': 'application/json' }
|
|
3244
3251
|
if (provider === 'cloudflare') {
|
|
3245
|
-
// 📖 Cloudflare uses account_id in the URL
|
|
3252
|
+
// 📖 Cloudflare uses account_id in the URL - resolveCloudflareUrl is
|
|
3246
3253
|
// 📖 already imported. We just need the standard Bearer header.
|
|
3247
3254
|
headers.Authorization = `Bearer ${apiKey}`
|
|
3248
3255
|
} else if (provider === 'replicate') {
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/core/schema-normalizer.js
|
|
3
|
+
* @description Per-provider request body normalization for the FCM Router.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* 📖 The router forwards chat completions to many providers that are
|
|
7
|
+
* 📖 *nominally* OpenAI-compatible but have different schema quirks.
|
|
8
|
+
* 📖 Without normalization, certain clients (especially ZCode, Claude Code,
|
|
9
|
+
* 📖 and Cline when their tool-call flow is enabled) hit 400/422 errors
|
|
10
|
+
* 📖 on GLM and Codestral that wouldn't fail on other OpenAI-compatible
|
|
11
|
+
* 📖 providers.
|
|
12
|
+
*
|
|
13
|
+
* 📖 The three offender patterns we see in the wild:
|
|
14
|
+
* 📖 1. Parameters that GLM/Mistral silently reject with 422
|
|
15
|
+
* 📖 → `parallel_tool_calls`, `n`, `top_k`, `logprobs`, ...
|
|
16
|
+
* 📖 2. `tool` role messages that lack a matching `tool_call_id`
|
|
17
|
+
* 📖 → happens when a client drops the assistant's tool_calls but
|
|
18
|
+
* 📖 keeps the tool result (e.g. after a partial response cut)
|
|
19
|
+
* 📖 3. Out-of-range numerics
|
|
20
|
+
* 📖 → Mistral rejects `temperature > 1` with 422
|
|
21
|
+
*
|
|
22
|
+
* 📖 `normalizeRequestBody(body, providerKey)` is the single entry point.
|
|
23
|
+
* 📖 It dispatches to a per-provider transform, or returns the body
|
|
24
|
+
* 📖 unchanged for providers that don't need any tweak.
|
|
25
|
+
*
|
|
26
|
+
* @functions
|
|
27
|
+
* → `normalizeRequestBody` — dispatcher; mutates a shallow copy
|
|
28
|
+
* → `stripUnsupportedParams` — removes known-bad parameters
|
|
29
|
+
* → `dropOrphanToolMessages` — removes tool messages without matching assistant tool_calls
|
|
30
|
+
* → `clampTemperature` — clamps `temperature` to the provider's accepted range
|
|
31
|
+
* → `normalizeZai` — for `zai` (GLM) provider
|
|
32
|
+
* → `normalizeMistral` — for `mistral` and `codestral` providers
|
|
33
|
+
*
|
|
34
|
+
* @exports normalizeRequestBody, normalizeZai, normalizeMistral, PROVIDER_NORMALIZERS
|
|
35
|
+
*
|
|
36
|
+
* @see src/core/router-daemon.js — calls `normalizeRequestBody` before forwarding upstream
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
// 📖 Parameters that GLM and Mistral-family endpoints commonly reject with 422
|
|
40
|
+
// 📖 even though they are valid in the OpenAI Chat Completions spec. Stripping
|
|
41
|
+
// 📖 them upstream is safe because the router's failover already picks a model
|
|
42
|
+
// 📖 per request — we never need n>1 or parallel calls.
|
|
43
|
+
const STRIP_PARAMS = [
|
|
44
|
+
'parallel_tool_calls', // not in GLM, Codestral, most Mistral
|
|
45
|
+
'n', // n>1 unsupported by most; always route n=1
|
|
46
|
+
'top_k', // not in OpenAI spec; GLM rejects
|
|
47
|
+
'logprobs', // GLM and Codestral reject
|
|
48
|
+
'echo', // not in OpenAI spec
|
|
49
|
+
'user', // PII risk; not used by FCM
|
|
50
|
+
'metadata', // not always supported
|
|
51
|
+
'store', // GLM rejects
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
// 📖 `stream_options` is only meaningful when stream=true. Some providers
|
|
55
|
+
// 📖 reject the field when stream=false, so we always strip it for non-streaming.
|
|
56
|
+
function stripStreamOptionsWhenNotStreaming(body) {
|
|
57
|
+
if (body.stream === true) return body
|
|
58
|
+
if (Object.prototype.hasOwnProperty.call(body, 'stream_options')) {
|
|
59
|
+
const next = { ...body }
|
|
60
|
+
delete next.stream_options
|
|
61
|
+
return next
|
|
62
|
+
}
|
|
63
|
+
return body
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function stripUnsupportedParams(body) {
|
|
67
|
+
if (!body || typeof body !== 'object') return body
|
|
68
|
+
let result = body
|
|
69
|
+
for (const key of STRIP_PARAMS) {
|
|
70
|
+
if (Object.prototype.hasOwnProperty.call(result, key)) {
|
|
71
|
+
if (result === body) result = { ...body }
|
|
72
|
+
delete result[key]
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return stripStreamOptionsWhenNotStreaming(result)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 📖 `dropOrphanToolMessages` removes tool messages whose `tool_call_id` does
|
|
79
|
+
// 📖 not match any preceding assistant `tool_calls[*].id`. The OpenAI spec
|
|
80
|
+
// 📖 requires a `tool` role message to be the result of a specific assistant
|
|
81
|
+
// 📖 tool call — but some clients (ZCode, Claude Code) drop the assistant
|
|
82
|
+
// 📖 tool_calls entry while keeping the tool result, which GLM rejects with 422.
|
|
83
|
+
function dropOrphanToolMessages(body) {
|
|
84
|
+
if (!Array.isArray(body.messages)) return body
|
|
85
|
+
const filtered = []
|
|
86
|
+
for (const msg of body.messages) {
|
|
87
|
+
if (!msg || typeof msg !== 'object') continue
|
|
88
|
+
if (msg.role === 'tool') {
|
|
89
|
+
const toolCallId = msg.tool_call_id
|
|
90
|
+
if (typeof toolCallId !== 'string' || toolCallId.length === 0) {
|
|
91
|
+
// 📖 tool message without a tool_call_id is fundamentally invalid
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
const prev = filtered[filtered.length - 1]
|
|
95
|
+
const hasMatch = prev
|
|
96
|
+
&& prev.role === 'assistant'
|
|
97
|
+
&& Array.isArray(prev.tool_calls)
|
|
98
|
+
&& prev.tool_calls.some((tc) => tc && tc.id === toolCallId)
|
|
99
|
+
if (!hasMatch) {
|
|
100
|
+
// 📖 Skip the orphan — better to drop than to 422
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
filtered.push(msg)
|
|
105
|
+
}
|
|
106
|
+
// 📖 If filtering changed anything, materialize a new body object
|
|
107
|
+
if (filtered.length === body.messages.length) return body
|
|
108
|
+
return { ...body, messages: filtered }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 📖 `clampTemperature` clamps temperature to [min, max]. Mistral's chat API
|
|
112
|
+
// 📖 rejects temperatures outside [0, 1] with 422; GLM accepts [0, 2].
|
|
113
|
+
function clampTemperature(body, { min = 0, max = 1 } = {}) {
|
|
114
|
+
if (typeof body.temperature !== 'number' || !Number.isFinite(body.temperature)) return body
|
|
115
|
+
if (body.temperature >= min && body.temperature <= max) return body
|
|
116
|
+
return { ...body, temperature: Math.max(min, Math.min(max, body.temperature)) }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 📖 `normalizeZai` — transforms a request body for the `zai` (GLM) provider.
|
|
121
|
+
*
|
|
122
|
+
* 1. Strips parameters GLM rejects with 422
|
|
123
|
+
* 2. Removes orphan `tool` messages that lack a matching assistant tool_call
|
|
124
|
+
* 3. Strips `stream_options` when not streaming
|
|
125
|
+
*/
|
|
126
|
+
export function normalizeZai(body) {
|
|
127
|
+
if (!body || typeof body !== 'object') return body
|
|
128
|
+
let result = stripUnsupportedParams(body)
|
|
129
|
+
result = dropOrphanToolMessages(result)
|
|
130
|
+
return result
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 📖 `normalizeMistral` — transforms a request body for `mistral` and `codestral`.
|
|
135
|
+
*
|
|
136
|
+
* 1. Strips parameters Mistral/Codestral reject with 422
|
|
137
|
+
* 2. Clamps `temperature` to [0, 1] (Mistral's accepted range)
|
|
138
|
+
* 3. Removes orphan `tool` messages
|
|
139
|
+
* 4. Strips `stream_options` when not streaming
|
|
140
|
+
*/
|
|
141
|
+
export function normalizeMistral(body) {
|
|
142
|
+
if (!body || typeof body !== 'object') return body
|
|
143
|
+
let result = stripUnsupportedParams(body)
|
|
144
|
+
result = clampTemperature(result, { min: 0, max: 1 })
|
|
145
|
+
result = dropOrphanToolMessages(result)
|
|
146
|
+
return result
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 📖 Map of provider key → normalizer function. Providers that don't need
|
|
150
|
+
// 📖 any tweak (most OpenAI-compat ones like Groq, Cerebras, etc.) are not
|
|
151
|
+
// 📖 listed and pass through `normalizeRequestBody` unchanged.
|
|
152
|
+
export const PROVIDER_NORMALIZERS = {
|
|
153
|
+
zai: normalizeZai,
|
|
154
|
+
mistral: normalizeMistral,
|
|
155
|
+
codestral: normalizeMistral,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 📖 `normalizeRequestBody` — public entry point. Returns a *new* body object
|
|
160
|
+
* 📖 if any transform was applied, or the original body if the provider has
|
|
161
|
+
* 📖 no normalizer registered (to keep call-site object identity stable).
|
|
162
|
+
*
|
|
163
|
+
* @param {unknown} body
|
|
164
|
+
* @param {string | null | undefined} providerKey
|
|
165
|
+
* @returns {unknown}
|
|
166
|
+
*/
|
|
167
|
+
export function normalizeRequestBody(body, providerKey) {
|
|
168
|
+
if (!body || typeof body !== 'object') return body
|
|
169
|
+
const normalizer = providerKey && PROVIDER_NORMALIZERS[providerKey]
|
|
170
|
+
if (!normalizer) return body
|
|
171
|
+
return normalizer(body)
|
|
172
|
+
}
|
|
@@ -315,6 +315,17 @@ export const TOOL_BOOTSTRAP_METADATA = {
|
|
|
315
315
|
},
|
|
316
316
|
},
|
|
317
317
|
},
|
|
318
|
+
zcode: {
|
|
319
|
+
// 📖 ZCode is a desktop IDE from z.ai (智谱) — there is no CLI binary to bootstrap.
|
|
320
|
+
// 📖 We launch it with `open -a ZCode` on macOS and print manual setup steps on other platforms.
|
|
321
|
+
binary: null,
|
|
322
|
+
docsUrl: 'https://zcode.z.ai/download',
|
|
323
|
+
installUnsupported: {
|
|
324
|
+
default: 'ZCode is a desktop application. Download it from zcode.z.ai/download for macOS or Windows, then FCM will auto-launch it via `open -a ZCode`.',
|
|
325
|
+
win32: 'Download the Windows installer from zcode.z.ai/download. FCM will print the custom-provider setup steps after launch.',
|
|
326
|
+
linux: 'ZCode does not currently ship an official Linux build.',
|
|
327
|
+
},
|
|
328
|
+
},
|
|
318
329
|
}
|
|
319
330
|
|
|
320
331
|
export function getToolBootstrapMeta(mode) {
|
|
@@ -828,6 +828,22 @@ export function prepareExternalToolLaunch(mode, model, config, options = {}) {
|
|
|
828
828
|
}
|
|
829
829
|
}
|
|
830
830
|
|
|
831
|
+
if (mode === 'zcode') {
|
|
832
|
+
// 📖 ZCode is a desktop app from z.ai. Launch via `open -a ZCode` on macOS.
|
|
833
|
+
// 📖 On other platforms the user is expected to have ZCode already running — the
|
|
834
|
+
// 📖 startExternalTool hook below prints manual setup steps and skips spawning.
|
|
835
|
+
const isMac = process.platform === 'darwin'
|
|
836
|
+
return {
|
|
837
|
+
command: isMac ? 'open' : 'true', // no-op on non-mac
|
|
838
|
+
args: isMac ? ['-a', 'ZCode'] : [],
|
|
839
|
+
env,
|
|
840
|
+
apiKey,
|
|
841
|
+
baseUrl,
|
|
842
|
+
meta,
|
|
843
|
+
configArtifacts: [],
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
831
847
|
if (mode === 'caveman') {
|
|
832
848
|
const cavemanEnv = buildCavemanEnv(model, config, { includeProviderEnv: options.includeProviderEnv })
|
|
833
849
|
console.log(chalk.dim(` 📖 Caveman Code will use model: ${model.modelId}`))
|
|
@@ -965,6 +981,24 @@ export async function startExternalTool(mode, model, config) {
|
|
|
965
981
|
console.log(chalk.white(` 4. Click Add, then select `) + chalk.bold(model.modelId) + chalk.white(` from the list.\n`))
|
|
966
982
|
console.log(chalk.dim(` 📖 Attempting to launch Xcode...`))
|
|
967
983
|
}
|
|
984
|
+
if (mode === 'zcode') {
|
|
985
|
+
// 📖 ZCode has UI-based config (Settings -> Model Settings / 模型设置). We point
|
|
986
|
+
// 📖 the user at the FCM router (OpenAI-compatible) so they get free failover +
|
|
987
|
+
// 📖 the per-provider normalizer on top of any FCM-tracked model.
|
|
988
|
+
const routerBase = 'http://localhost:19280/v1'
|
|
989
|
+
console.log(chalk.bold.cyan('\n 🧊 ZCode Setup Instructions:'))
|
|
990
|
+
console.log(chalk.white(' 1. Open ZCode and click the model selector in the chat input.'))
|
|
991
|
+
console.log(chalk.white(' 2. At the bottom of the list, click ') + chalk.bold('Manage Models') + chalk.white(' (管理模型)'))
|
|
992
|
+
console.log(chalk.white(' 3. Click ') + chalk.bold('Add Provider') + chalk.white(' (添加供应商) in the left sidebar.'))
|
|
993
|
+
console.log(chalk.white(' 4. Fill in the following details:'))
|
|
994
|
+
console.log(chalk.dim(' Name: ') + chalk.green(`FCM Router`))
|
|
995
|
+
console.log(chalk.dim(' Base URL: ') + chalk.green(routerBase))
|
|
996
|
+
console.log(chalk.dim(' API Key: ') + chalk.green('fcm-local'))
|
|
997
|
+
console.log(chalk.dim(' Protocol: ') + chalk.green('OpenAI-compatible'))
|
|
998
|
+
console.log(chalk.white(' 5. Save, then pick ') + chalk.bold(`fcm`) + chalk.white(' from the model list. FCM picks the best live model.'))
|
|
999
|
+
console.log(chalk.dim(` 📖 If you prefer a direct provider, use the URL/key for ${chalk.bold(sources[model.providerKey]?.name || model.providerKey)} shown above.\n`))
|
|
1000
|
+
console.log(chalk.dim(` 📖 Attempting to launch ZCode...`))
|
|
1001
|
+
}
|
|
968
1002
|
if (mode === 'crush') console.log(chalk.dim(' 📖 Crush will use the provider directly for this launch.'))
|
|
969
1003
|
|
|
970
1004
|
// 📖 Tool-specific info messages (only for modes that have no prepare-step message)
|
|
@@ -978,10 +1012,11 @@ export async function startExternalTool(mode, model, config) {
|
|
|
978
1012
|
jcode: ' 📖 Launching jcode...',
|
|
979
1013
|
copilot: ` 📖 Copilot CLI configured with model: ${model.modelId}`,
|
|
980
1014
|
forgecode: ` 📖 ForgeCode configured with model: ${model.modelId}`,
|
|
1015
|
+
zcode: ` 📖 ZCode is a desktop app — setup instructions printed below.`,
|
|
981
1016
|
}
|
|
982
1017
|
if (infoMessages[mode]) console.log(chalk.dim(infoMessages[mode]))
|
|
983
1018
|
|
|
984
|
-
// 📖 xcode
|
|
985
|
-
const command = mode === 'xcode' ? launchPlan.command : resolveLaunchCommand(mode, launchPlan.command)
|
|
1019
|
+
// 📖 xcode and zcode use raw command ("open"), everything else resolves via tool-bootstrap
|
|
1020
|
+
const command = (mode === 'xcode' || mode === 'zcode') ? launchPlan.command : resolveLaunchCommand(mode, launchPlan.command)
|
|
986
1021
|
return spawnCommand(command, launchPlan.args, launchPlan.env)
|
|
987
1022
|
}
|
|
@@ -48,6 +48,7 @@ export const TOOL_METADATA = {
|
|
|
48
48
|
fcm_router: { label: 'FCM Router', emoji: '🧭', flag: '--fcm-router', color: [80, 200, 120] },
|
|
49
49
|
copilot: { label: 'Copilot CLI', emoji: '🤖', flag: '--copilot', color: [200, 220, 255] },
|
|
50
50
|
forgecode: { label: 'ForgeCode', emoji: '🔥', flag: '--forgecode', color: [255, 120, 50] },
|
|
51
|
+
zcode: { label: 'ZCode', emoji: '🧊', flag: '--zcode', color: [60, 160, 230] },
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
// 📖 Deduplicated emoji order for the "Compatible with" column.
|
|
@@ -74,6 +75,7 @@ export const COMPAT_COLUMN_SLOTS = [
|
|
|
74
75
|
{ emoji: '🛠️', toolKeys: ['xcode'], color: [20, 126, 251] },
|
|
75
76
|
{ emoji: '🤖', toolKeys: ['copilot'], color: [200, 220, 255] },
|
|
76
77
|
{ emoji: '🔥', toolKeys: ['forgecode'], color: [255, 120, 50] },
|
|
78
|
+
{ emoji: '🧊', toolKeys: ['zcode'], color: [60, 160, 230] },
|
|
77
79
|
]
|
|
78
80
|
|
|
79
81
|
export const TOOL_MODE_ORDER = [
|
|
@@ -99,6 +101,7 @@ export const TOOL_MODE_ORDER = [
|
|
|
99
101
|
'caveman',
|
|
100
102
|
'copilot',
|
|
101
103
|
'forgecode',
|
|
104
|
+
'zcode',
|
|
102
105
|
]
|
|
103
106
|
|
|
104
107
|
export function getToolMeta(mode) {
|