free-coding-models 0.5.15 β†’ 0.5.17

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.
@@ -0,0 +1,15 @@
1
+ # Changelog v0.5.16 - 2026-06-04
2
+
3
+ ### Added
4
+ - **🌐 Expandable detail rows in the web dashboard** β€” clicking any model row now expands an inline 3‑column panel below it (accordion style, one row at a time), replacing the old side panel.
5
+ - **πŸ’¬ Inline Mini Playground** β€” middle column of the expanded row lets you chat one‑on‑one with the selected model via SSE streaming. Type a message, hit Enter, and watch the response appear in real time.
6
+ - **⚑ Inline AI Latency benchmark** β€” right column adds a "Test AI Latency" button that runs a live streaming benchmark against the selected model. Token count, TPS, latency, and generated text are shown as they stream in, driven by a new `/api/benchmark-stream` SSE endpoint on the server.
7
+ - **πŸ“Š Model Info column** β€” left column of the expanded row shows tier, SWE‑bench score, context window, provider, status, avg ping, stability, verdict, and uptime at a glance, plus favorite toggle and launch buttons.
8
+ - **πŸ“ˆ PostHog analytics** β€” basic event tracking for `app_web_start` (app mount) and `app_router_start` (router open). PostHog snippet injected in `index.html`.
9
+
10
+ ### Changed
11
+ - **DetailPanel removed** from the web dashboard β€” replaced entirely by the inline expandable rows. The old side‑panel had a CSS bug (never slid in); expand rows are always visible when triggered.
12
+ - **ModelTable** now accepts `onToast`, `onSetToolMode`, `onCycleToolMode`, and `onOpenFallback` props for use in the expanded row.
13
+
14
+ ### Fixed
15
+ - DetailPanel slide‑in animation (was broken with `translateX(100%)` and no open‑state class) β€” moot since it was removed, but it was patched during exploration.
@@ -0,0 +1,13 @@
1
+ # Changelog v0.5.17 - 2026-06-08
2
+
3
+ ### Fixed
4
+ - **Docker provider key mapping** β€” replaced broken `sed` logic with a proper Node.js init script (`scripts/docker-init.mjs`). The script imports `ENV_VARS` from `config.js` (single source of truth) and generates clean entrypoint files. `docker-entrypoint.sh` simplified to a 1‑line Node call. Added `/api/key/:provider/test` POST endpoint to the daemon (was 404), and fixed the key test probe to use the first valid model from the provider instead of an empty string.
5
+ - **Docker compose** β€” cleaned 9 stale providers, added `GEMINI_API_KEY` and `OPENCODE_ZEN_API_KEY` support.
6
+
7
+ ### Updated
8
+ - **kandown** bumped from 0.4.0 β†’ 0.8.0 (minor version bump, improved task kanban).
9
+ - **vite-plus** bumped to 0.1.24 (dependency fix).
10
+ - **docker/setup-qemu-action** bumped from 3 β†’ 4 in CI (major bump, multi‑arch builds).
11
+
12
+ ### Added (test coverage)
13
+ - Added tests for Docker init script key mapping and `/api/key/:provider/test` endpoint (see `test/test.js`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.5.15",
3
+ "version": "0.5.17",
4
4
  "description": "Find the fastest coding LLM models in seconds β€” ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
5
5
  "keywords": [
6
6
  "nvidia",
@@ -67,7 +67,7 @@
67
67
  "@tabler/icons-react": "^3.44.0",
68
68
  "@tanstack/react-table": "^8.21.3",
69
69
  "chalk": "^5.6.2",
70
- "kandown": "^0.4.0",
70
+ "kandown": "^0.8.0",
71
71
  "socket.io": "^4.8.3",
72
72
  "socket.io-client": "^4.8.3"
73
73
  },
@@ -80,6 +80,6 @@
80
80
  "react": "^19.2.7",
81
81
  "react-dom": "^19.2.7",
82
82
  "vite": "^8.0.16",
83
- "vite-plus": "^0.1.23"
83
+ "vite-plus": "^0.1.24"
84
84
  }
85
85
  }
@@ -108,6 +108,7 @@ import { syncShellEnv } from './shell-env.js'
108
108
 
109
109
  // πŸ“– New JSON config path β€” stores all providers' API keys + enabled state
110
110
  export const CONFIG_PATH = join(homedir(), '.free-coding-models.json')
111
+ export { ENV_VARS }
111
112
 
112
113
  // πŸ“– Runtime data directory β€” backups and local snapshots live here.
113
114
  export const DAEMON_DATA_DIR = join(homedir(), '.free-coding-models')
@@ -47,7 +47,7 @@ import {
47
47
  normalizeRouterConfig,
48
48
  saveConfig,
49
49
  } from './config.js'
50
- import { buildChatCompletionPingBody, resolveCloudflareUrl, shouldUseDisabledThinkingForProvider } from './ping.js'
50
+ import { buildChatCompletionPingBody, ping, resolveCloudflareUrl, shouldUseDisabledThinkingForProvider } from './ping.js'
51
51
  import { benchmarkModel, BENCHMARK_TIMEOUT_MS } from './benchmark.js'
52
52
  import { sendUsageTelemetry } from './telemetry.js'
53
53
 
@@ -73,6 +73,7 @@ export function getRouterPortRange() {
73
73
 
74
74
  const __dirname = dirname(fileURLToPath(import.meta.url))
75
75
  const CLI_ENTRY_PATH = join(__dirname, '..', '..', 'bin', 'free-coding-models.js')
76
+ const LOCAL_VERSION = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8')).version
76
77
  const MAX_BODY_BYTES = 10 * 1024 * 1024
77
78
  const MAX_REQUEST_LOG = 200
78
79
  const MAX_SSE_CLIENTS = 10
@@ -2537,6 +2538,22 @@ class RouterRuntime {
2537
2538
  sendJson(res, 200, getWebModelsPayload(this), { 'x-request-id': requestId })
2538
2539
  return
2539
2540
  }
2541
+ // πŸ“– Stub endpoints for the web dashboard's hooks (useToolMode, useFavorites,
2542
+ // πŸ“– useUpdateChecker). These were 404 before β€” minimal shapes that match
2543
+ // πŸ“– what the dashboard hooks expect. See PR #108 for context.
2544
+ if (req.method === 'GET' && (url.pathname === '/api/tool-mode')) {
2545
+ sendJson(res, 200, { mode: 'opencode', tools: ['opencode', 'openclaw', 'opencode-desktop', 'opencode-web'] }, { 'x-request-id': requestId })
2546
+ return
2547
+ }
2548
+ if (req.method === 'GET' && (url.pathname === '/api/favorites')) {
2549
+ const cfg = this.config || {}
2550
+ sendJson(res, 200, { favorites: cfg.favorites || [], pinnedAndSticky: Boolean(cfg.settings?.favoritesPinnedAndSticky) }, { 'x-request-id': requestId })
2551
+ return
2552
+ }
2553
+ if (req.method === 'GET' && (url.pathname === '/api/version')) {
2554
+ sendJson(res, 200, { local: LOCAL_VERSION, latest: null, lastReleaseDate: null, error: null }, { 'x-request-id': requestId })
2555
+ return
2556
+ }
2540
2557
  // πŸ“– /api/router/catalog β€” lightweight catalog of routeable models for
2541
2558
  // πŸ“– the Web Router Dashboard's "Add model" picker. Returns one row
2542
2559
  // πŸ“– per (provider, model) with `key`, label, tier, ctx. We filter to
@@ -2673,21 +2690,50 @@ class RouterRuntime {
2673
2690
  sendJson(res, result.started ? 202 : 409, result, { 'x-request-id': requestId })
2674
2691
  return
2675
2692
  }
2676
- if (req.method === 'GET' && url.pathname.startsWith('/api/key/')) {
2677
- // πŸ“– Reveals raw API keys β€” same-origin only to prevent malicious sites
2678
- // πŸ“– from exfiltrating provider credentials via XHR/fetch.
2693
+ if (url.pathname.startsWith('/api/key/')) {
2679
2694
  if (!isSameOriginOrLocal(req)) {
2680
2695
  sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
2681
2696
  return
2682
2697
  }
2683
- const providerKey = decodeURIComponent(url.pathname.slice('/api/key/'.length))
2684
- if (!providerKey || !sources[providerKey]) {
2685
- sendError(res, 404, 'Unknown provider', 'invalid_request_error', 'unknown_provider', requestId)
2698
+ const testMatch = url.pathname.match(/^\/api\/key\/([^/]+)\/test$/)
2699
+ if (testMatch && req.method === 'POST') {
2700
+ const providerKey = decodeURIComponent(testMatch[1])
2701
+ if (!sources[providerKey]) {
2702
+ sendError(res, 404, 'Unknown provider', 'invalid_request_error', 'unknown_provider', requestId)
2703
+ return
2704
+ }
2705
+ const apiKey = this.getApiKeyForProvider(providerKey)
2706
+ if (!apiKey) {
2707
+ sendJson(res, 200, { outcome: 'missing_key', detail: `${providerKey} has no saved API key.` }, { 'x-request-id': requestId })
2708
+ return
2709
+ }
2710
+ const providerModels = sources[providerKey]?.models || []
2711
+ const modelId = providerModels[0]?.[0] || ''
2712
+ try {
2713
+ const result = await ping(apiKey, modelId, providerKey, sources[providerKey].url)
2714
+ const code = result?.code
2715
+ if (code === '200') {
2716
+ sendJson(res, 200, { outcome: 'ok', code: 200 }, { 'x-request-id': requestId })
2717
+ } else if (code === '401' || code === '403') {
2718
+ sendJson(res, 200, { outcome: 'auth_error', code: Number(code) || code }, { 'x-request-id': requestId })
2719
+ } else {
2720
+ sendJson(res, 200, { outcome: 'fail', code: code ?? 'ERR', detail: 'Probe did not return a 2xx' }, { 'x-request-id': requestId })
2721
+ }
2722
+ } catch (err) {
2723
+ sendJson(res, 200, { outcome: 'fail', detail: err.message || 'Probe failed' }, { 'x-request-id': requestId })
2724
+ }
2725
+ return
2726
+ }
2727
+ if (req.method === 'GET') {
2728
+ const providerKey = decodeURIComponent(url.pathname.slice('/api/key/'.length))
2729
+ if (!providerKey || !sources[providerKey]) {
2730
+ sendError(res, 404, 'Unknown provider', 'invalid_request_error', 'unknown_provider', requestId)
2731
+ return
2732
+ }
2733
+ const rawKey = this.getApiKeyForProvider(providerKey)
2734
+ sendJson(res, 200, { key: rawKey || null }, { 'x-request-id': requestId })
2686
2735
  return
2687
2736
  }
2688
- const rawKey = this.getApiKeyForProvider(providerKey)
2689
- sendJson(res, 200, { key: rawKey || null }, { 'x-request-id': requestId })
2690
- return
2691
2737
  }
2692
2738
  if (req.method === 'POST' && url.pathname === '/api/settings') {
2693
2739
  // πŸ“– Writes API keys + provider toggles β€” same-origin only to block
@@ -2881,7 +2927,7 @@ const PREFERRED_DEFAULT_MODELS = [
2881
2927
  * @param {object} [options] { probeFn: async (entry) => ({ ok, latencyMs, code }) }
2882
2928
  * @returns {{ name: string, models: Array, created: string }}
2883
2929
  */
2884
- export async function buildDefaultRouterSet(config = {}, maxModels = 5, options = {}) {
2930
+ export async function buildDefaultRouterSet(config = {}, maxModels, options = {}) {
2885
2931
  const probeFn = typeof options.probeFn === 'function' ? options.probeFn : null
2886
2932
  const probeTimeoutMs = typeof options.probeTimeoutMs === 'number' ? options.probeTimeoutMs : 1500
2887
2933
  const probeBudget = typeof options.probeBudget === 'number' ? options.probeBudget : 24
@@ -2890,6 +2936,10 @@ export async function buildDefaultRouterSet(config = {}, maxModels = 5, options
2890
2936
  .filter(([, value]) => (Array.isArray(value) ? value.length > 0 : typeof value === 'string' && value.trim()))
2891
2937
  .map(([provider]) => provider))
2892
2938
 
2939
+ // πŸ“– Scale default set size with configured providers so users with many
2940
+ // πŸ“– keys get a richer default router set (PR #108 idea, kept from the
2941
+ // πŸ“– previous sync version).
2942
+ if (maxModels === undefined) maxModels = Math.max(5, keyedProviders.size * 2)
2893
2943
  const entries = []
2894
2944
  for (const [providerKey, source] of Object.entries(sources)) {
2895
2945
  if (!isRouteableProvider(providerKey)) continue