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.
Files changed (36) hide show
  1. package/LICENSE +43 -21
  2. package/README.md +2 -1
  3. package/changelog/v0.5.33.md +4 -0
  4. package/changelog/v0.5.34.md +4 -0
  5. package/changelog/v0.5.35.md +4 -0
  6. package/package.json +3 -2
  7. package/sources.js +12 -52
  8. package/src/core/router-daemon.js +56 -49
  9. package/src/core/schema-normalizer.js +172 -0
  10. package/src/core/tool-bootstrap.js +11 -0
  11. package/src/core/tool-launchers.js +37 -2
  12. package/src/core/tool-metadata.js +3 -0
  13. package/web/dist/assets/index-B5tTa7_O.js +40 -0
  14. package/web/dist/assets/index-r8niexgJ.css +1 -0
  15. package/web/dist/index.html +2 -2
  16. package/web/src/App.jsx +93 -105
  17. package/web/src/components/changelog/ChangelogView.module.css +13 -14
  18. package/web/src/components/dashboard/FilterBar.jsx +17 -2
  19. package/web/src/components/dashboard/FilterBar.module.css +68 -21
  20. package/web/src/components/dashboard/ModelTable.jsx +118 -42
  21. package/web/src/components/dashboard/ModelTable.module.css +82 -27
  22. package/web/src/components/help/HelpView.module.css +13 -14
  23. package/web/src/components/install/InstallEndpointsView.module.css +13 -13
  24. package/web/src/components/installed/InstalledModelsView.module.css +13 -13
  25. package/web/src/components/layout/Header.jsx +28 -28
  26. package/web/src/components/layout/Header.module.css +56 -30
  27. package/web/src/components/palette/CommandPalette.jsx +14 -14
  28. package/web/src/components/playground/PlaygroundView.module.css +13 -14
  29. package/web/src/components/recommend/RecommendView.module.css +13 -11
  30. package/web/src/components/router/RouterView.module.css +13 -13
  31. package/web/src/components/tools/ToolPicker.module.css +27 -14
  32. package/web/src/global.css +58 -31
  33. package/web/src/hooks/urlState.constants.js +1 -1
  34. package/web/src/hooks/useUrlState.js +19 -3
  35. package/web/dist/assets/index-C_tCF0A5.js +0 -40
  36. 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() 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
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 router config is persisted under `router`
29
- * @see ../sources.js provider URLs and model IDs are resolved from the catalog
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 they are only used by the daemon child process
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 used by the TUI dashboard which may have FCM_DEV
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 it returns {ok, value/error} which is different from safeJsonParse
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.1631.x,
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 ? '' : avg < 1000 ? 'Excellent' : avg < 2000 ? 'Good' : avg < 4000 ? 'Fair' : 'Poor'
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 flip `router.userCustomized = true` and
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 the ordered list of models the router will try,
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 which
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 GPT-OSS 120B served despite higher-priority models
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 slim projection of getRoutingCandidates for the /stats
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" both hit /api/router/status but read different fields.
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 the exact attempt order for the next request
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 powers the Router Dashboard's
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} ${latencyMs}ms`)
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 replaces broken models in the active set with
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 we need healthy
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 we use these to detect "the user's
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) those should resolve on their own.
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
- ...bodyWithPrePrompt,
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} ${latencyMs}ms`, { request_id: requestId, status: response.status })
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
- ...bodyWithPrePrompt,
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 append a single model to a set. The model
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 remove a single model from a set.
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 accept a full priority order from the
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 reordering
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 re-run the probe-based sync-set pipeline
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 minimal shapes that match
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 lightweight catalog of routeable models for
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 same-origin only to block
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) never a hard requirement, so
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 used by buildDefaultRouterSet to find models
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 resolveCloudflareUrl is
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 uses raw command ("open"), everything else resolves via tool-bootstrap
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) {