chore(sync): upstream v0.8.5 — loopback proxy guard, custom endpoint identity, analytics provider labels #6

Closed
epistemophiliac wants to merge 0 commits from upstream-v0.8.5 into main

Upstream release v0.8.5 (tashfeenahmed/freellmapi). Synced via branch+PR per ci skill — never stash-and-restore. Conflicts expected in shared server files (our fork diverged); Aexora-layer files untouched.

Upstream release v0.8.5 (tashfeenahmed/freellmapi). Synced via branch+PR per ci skill — never stash-and-restore. Conflicts expected in shared server files (our fork diverged); Aexora-layer files untouched.
Adds a self-maintaining test guard: the modules declared pure in lib/ must stay free of value imports, and any new import-free module fails the suite until it is classified as guarded or not. Catches the fusion↔proxy import cycle regressing, and the detector covers dynamic imports, re-exports and require().
Three fixes to how the CLI resolves a pinned model:

- `--model` was parsed into CliOptions and then dropped before it reached the setup generators, so `setup-<tool> --model X` silently wrote whatever primaryModel() preferred.
- `--model` was checked against the `?available=true` roster only, so a model that exists but is out of quota was reported as an unknown id. It is now validated against the unfiltered catalog, and a registered-but-unavailable model launches with a warning instead of an error. A failed unfiltered fetch degrades to the filtered roster and says so.
- A model with no published context window had one invented as 128_000 for CLAUDE_CODE_AUTO_COMPACT_WINDOW. The variable is now left unset so Claude Code applies its own default.
Adds `freellmapi doctor [tool…]`, which resolves a coding agent's effective base URL the way that tool resolves it — in a process inheriting the user's session environment — and then probes /livez.

The failure it diagnoses leaves nothing in any server-side log, because the request never arrives: a launcher that writes ANTHROPIC_BASE_URL can be outranked by a settings.json env block (or vice versa), and nothing says so. Verdicts separate the cases that look alike: routed, shadowed (a lower layer set a different URL and something outranks it), elsewhere (a different endpoint, or the right port answering something that is not this gateway), degraded (this gateway, reporting that it cannot serve), unreachable, unknown. Nonzero exit unless every tool is routed, so it is usable as a script precondition.

Includes two follow-up fixes: the stray-positional check now runs before the setup-* dispatch (it had moved below it, so 'setup-claude typo' silently ignored the word), and the managed-settings scan now reads the managed-settings.d/ drop-in directory, ranked above the base file with the alphabetically-last drop-in first.
Adds a Test button beside Save in Settings → Outbound proxy. It probes a draft proxy URL without saving it, falls back to the saved URL when the field is empty, and runs direct when no proxy is configured at all, so the button is useful before anything has been set up. Any HTTP response counts as success — a 401 or 403 without a key still proves the route connected; only a network-level failure is reported as a proxy failure. SOCKS URLs route through socksFetch, HTTP(S) through an undici dispatcher, and an unbuildable agent comes back as a structured error rather than a throw.

Fixed before merge: the probe was hardcoded to https://api.openai.com/v1/models, which made the verdict wrong in both directions — an install that never calls OpenAI pinged it on every click, and a network where that host is blocked reported a working proxy as broken, which is the population most likely to be configuring a proxy. The target is now chosen by the caller: the /models endpoint of a provider the operator holds an enabled key for, preferring one not already bypassing the proxy so the probe exercises the proxy path. PROXY_TEST_URL overrides it, and only an install with no keys falls back to a neutral, non-vendor reachability endpoint. The probed target is returned with the result and its host is shown next to the verdict.
Adds an opt-in Claude Code PreToolUse hook that reads CONTRIBUTING.md at fire time and reminds the agent to check its diff against the repo's rules before `git commit` / `git push`. It reminds; it does not block.

Nothing is wired up by this change: no .claude/settings.json is added, so cloning the repo changes no behavior. Contributors who want it paste the snippet from the new CONTRIBUTING.md section into their own .claude/settings.local.json. `node .claude/hooks/contributing-check.mjs --preview` shows what it would say.

9 tests, no new dependencies, wired into `npm test` as `test:hooks`.
The model detail page never displayed the unified model id — the id a caller
actually puts in a request body. It was only reachable via the hover copy
button in the Models table (#708).

The summary chips now lead with `canonicalId` plus a copy button, and the
per-provider member id in ProviderSettingsRow gets the same pill treatment and
its own copy button. Both keep `min-w-0 truncate` so long ids elide instead of
stretching the row.

No new i18n keys — reuses `models.copyModelId`, so all 60 locales stay in
parity (check-i18n: 60 locales / 820 keys).

Closes #725. Refs #708.
probeEndpointModel pinned max_tokens to 1 to keep the probe cheap. Several relays enforce a floor above that and reject the request outright: b.ai's deepseek-v4-flash 400s with "max_tokens must be greater than 2", so a working endpoint was reported as broken and kept its cooldown.

Raise it to 4 via an exported PROBE_MAX_TOKENS. That clears every floor seen so far and still costs a rounding error per probe. Fixed generally rather than special-cased per provider, since the floor is not unique to one relay.

The existing test pinned the literal 1, so it now asserts the constant, and a new case drives a mock upstream that 400s anything at or below 2.

providers/modelscope.ts keeps max_tokens: 1 for key validation, where it works, so this change stays single-purpose.

Closes #903.
chore(release): v0.8.1 (#928)
Some checks failed
Docker / Merge manifest and push (pull_request) Has been cancelled
CI / Test & build (Node 22) (pull_request) Has been cancelled
Desktop release / Build (ubuntu-latest) (pull_request) Has been cancelled
Desktop release / Build (macos-latest) (pull_request) Has been cancelled
Desktop release / Build (windows-2022) (pull_request) Has been cancelled
CI / Test & build (Node 20) (pull_request) Has been cancelled
Docker / Build (linux/amd64) (pull_request) Has been cancelled
Docker / Build (linux/arm64) (pull_request) Has been cancelled
d8ba550c57
App 0.8.0 -> 0.8.1. CLI 0.3.0 -> 0.4.0, unblocking the npm publish of freellmapi doctor (#862) and the --model catalog fix (#861), which had been on main but unpublished since 2026-07-27.
Co-authored-by: Asuan <[email protected]>
* test: fusion+image must not be rejected with 422 fusion_no_vision

* feat(proxy): allow fusion to receive image requests, propagate hasImage

* test: selectPanel must filter panel models on supportsVision

* feat(fusion): vision-aware panel selection (requireVision)

* test: judge messages must not carry image blocks in vision mode

* feat(fusion): strip image blocks from judge messages in vision mode

* test: cover stripImagesFromMessages edge cases and judge default

* refactor(fusion): clarify empty-panel error for vision requests

* chore(fusion): match the tools filter's truthiness style in the vision filter

`supportsVision` is a SQLite 0/1 column like `supportsTools`, and the
explicit-panel branch two lines up already tests it for truthiness. Use
the same form in the auto-chain filter so both capability gates read
identically.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

---------

Co-authored-by: Icesenator <[email protected]>
Co-authored-by: Icesenator <[email protected]>
Co-authored-by: Tash <[email protected]>
Co-authored-by: Tashfeen <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
* fix(modelscope): stop health checks from burning magic-grain quota

ModelScope validates API keys with a paid 1-token chat completion
(GET /v1/models does not enforce auth), which spends the account's
magic-grain (魔粒) quota per probe — observed as 2 魔粒 per ultra-tier
request. With the default 5-minute health pass, a configured key burns
~288 paid probes per day.

Two mitigations keep proactive validation near zero cost:

- Tokens not prefixed `ms-` are rejected locally with no network call.
- A successful validation is cached per key for
  MODELSCOPE_VALIDATE_CACHE_MS (default 24h); repeat health passes
  return true without re-probing.

A revoked key is still caught by the next real request's 401 handling
(error-classify disables it), so the cache only delays proactive
detection, never hides a live failure.

* fix(modelscope): drop the unverified ms- shape rule, fingerprint the cache

Two follow-ups to the validation-cost fix:

- The `ms-` prefix pre-check rejected keys locally on an assumption
  nobody on the project can verify (the class comment says outright we
  have no real ModelScope token). If the platform ever mints a token in
  another shape, a valid key gets a confusing hard failure. The 24h cache
  is what actually takes the health pass from ~288 paid probes per key
  per day down to one, so the shape rule buys nothing on valid keys and
  risks locking out real ones.

- The cache was keyed on the api_keys row id, so editing a key's value in
  place inherited the old token's verdict for up to 24h. Key it on a
  sha256 of the token instead: rotating a key re-probes, no plaintext is
  retained, and the cache no longer needs a quota context to work.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

---------

Co-authored-by: Tashfeen <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
* feat(ui): merge custom-provider model aliases into a unified model (#790)

Custom providers can serve a model under a different id (an alias). Requests
for the primary model id would not reach it, because the alias lives in its
own unify group. This adds a "Merge model aliases" section to the model
detail page that persists a `merges` override (the same model_unify_overrides
store the split control writes), so a request for the unified id is also
served through the alias.

- ModelDetailPage: input + Add button writes { into: this group's label,
  keys: [alias] } into the merges list; existing aliases targeting this group
  are listed with a Remove button; Enter submits.
- i18n: en / zh-CN / zh-TW + 57 locales (aliasMergeHeading/Hint/Add/Remove).

* fix(ui): resolve #790 alias-merge TS errors (label hoisting + merges type)

The alias-merge block referenced `label` before its declaration and typed the
mutation parameter as string[] instead of the merges entries. Move the
groupMerges/addAlias/removeAlias helpers after `label` and pass
UnifyOverrides['merges'] to the PUT.

* fix(models): edit alias merges by value, not by visible row index

Two bugs in the alias control, both from expressing an edit against the
visible slice instead of the full overrides list:

- Adding a second alias dropped the first. The add path replaced the
  group's whole entry with `keys: [key]`, so each add silently discarded
  every alias already merged into that model.
- Remove deleted the wrong entry. The rendered rows are filtered to this
  group, but the index they yield was applied to the unfiltered merges
  array, so removing the first row on screen could delete an unrelated
  model's alias.

The mapping now lives in lib/alias-merge as pure add/remove/list helpers
keyed on the alias value, covered by unit tests including the
cross-group case. The list also renders one row per alias rather than one
comma-joined row per entry, so each Remove button names what it removes.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

---------

Co-authored-by: suantea <[email protected]>
Co-authored-by: Tashfeen <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
First contributions: fusion vision support (#884) and the ModelScope
health-check quota fix (#882). The other recent PR authors — @suantea
(#883, #868) and @ousamabenyounes (#893) — are already listed.

Co-authored-by: Tashfeen <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Global degraded mode: when the healthy-provider ratio stays below the
threshold (default 50%, DEGRADED_HEALTHY_RATIO) for a sustained period
(DEGRADED_ENTRY_GRACE_MS, 60s), the gateway flips to degraded and the
router skips bandit exploration so retry budget isn't burned on dead
providers. Recovery needs the ratio back above threshold for a longer
grace (DEGRADED_EXIT_GRACE_MS, 120s). Health pass updates the state
after every run; GET /api/health exposes `degradation` status.

Co-authored-by: suantea <[email protected]>
在路由压力面板的冷却 chip 上新增重置按钮,点击后调用
DELETE /api/keys/:id/cooldowns 立即解除该 key 的冷却,无需等待
升级冷却(最长 24h)自然到期;成功后刷新压力面板与密钥列表。
服务端端点已存在,本次仅补客户端 UI 与 60 个 locale 的 i18n。

Co-authored-by: Asuan <[email protected]>
Co-authored-by: AtomCode (deepseek-v4-flash) <[email protected]>
Supersedes the stacked #922/#923/#924 (each was a strict superset of the
last, so merging more than one conflicts).

Wires each platform at every entry point, not just the server registry.
The original PRs registered the provider and the Platform type but skipped
key-parser.ts and the Keys page, which left all of them unreachable: no way
to add a key in the dashboard, and no way to import one from a .env or an
auth.json. New test covers all four registration points together so a
half-wired platform fails CI instead of shipping.

Tencent Hunyuan is deliberately NOT included. hunyuan-lite was made free in
May 2024, which is where the original PR text came from, but Tencent's
current pricing doc no longer lists that model and the free allowance is now
a one-time 1M-token grant valid for one year with no replenishment. That
does not meet the recurring-free bar this catalog is for.

Provider facts corrected against vendor docs while transcribing:
  - LongCat is Meituan (美团), not 面壁智能.
  - LongCat's launch free tier is 100K tokens/day; the 50M/day Flash-Lite
    figure was announced as a future plan, so it is not claimed here.
  - iFlytek publishes no token ceiling or QPS number for Spark Lite, so
    neither is asserted.
  - Volcengine's recurring daily per-model quota (2M tokens/day) is real and
    is the strongest free tier of the four.

All four need Chinese real-name verification before a key serves traffic, so
the Keys page labels say so up front rather than letting a user mint a key
that 401s on every call (the ModelScope lesson, #581). LongCat is the one
that accepts an overseas email signup.

Catalog rows are NOT included here: model data ships through the signed
hosted catalog, never a migration (catalog/ops/README.md).
Claude Desktop's third-party gateway discovery fetched /v1/models over
HTTP 200, got the full free catalog back, and still reported "found 0
models". It only accepts ids belonging to a Claude family, so a list of
qwen3.5-397b / gpt-oss-120b style ids is rejected wholesale.

/v1/messages has always served those ids: classifyClaudeFamily maps any
claude-* alias onto the free pool through the operator's family map. Only
discovery never mentioned them, so a picker had nothing to select.

List one canonical id per family (opus, sonnet, haiku) alongside the real
catalog. The display name says where the request actually goes ("Sonnet
slot (auto-routed to a free model)", or the pinned model when the operator
pinned one), so no entry reads as hosted Claude. They are listed only when
some model can actually serve them, otherwise discovery would trade "0
models" for a model that 503s.

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
The four rate-limit fields on the model detail page were bare acronyms
sharing one generic tooltip, "Leave blank for no limit.", so nothing on
screen said what any of them stood for.

Each field now carries its own tooltip that spells the acronym out before
repeating the blank-for-no-limit guidance, reusing NumberField's existing
hint prop so no new UI is introduced. The generic limitHint key it
replaces is now unreferenced and is removed from all 60 locales.

Translations reuse each locale's own already-translated blank-for-no-limit
sentence verbatim, so the two halves cannot drift apart, and follow the
terminology each file already uses for token and request. Per
docs/translating.md that means 词元 in zh-CN and Token left in Latin for
zh-TW.

The labels themselves stay as the acronyms. RPM reads the same in every
locale and the tooltip is what carries the meaning.

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Fix a batch of routing, streaming, and deployment quick wins (#941)
Some checks failed
Docker / Merge manifest and push (pull_request) Has been cancelled
CI / Test & build (Node 20) (pull_request) Has been cancelled
CI / Test & build (Node 22) (pull_request) Has been cancelled
Desktop release / Build (ubuntu-latest) (pull_request) Has been cancelled
Desktop release / Build (macos-latest) (pull_request) Has been cancelled
Desktop release / Build (windows-2022) (pull_request) Has been cancelled
Docker / Build (linux/amd64) (pull_request) Has been cancelled
Docker / Build (linux/arm64) (pull_request) Has been cancelled
a9895bc557
Server:
- proxy: capture streaming `usage` from choice-bearing frames too, and
  emit it to the client exactly once (after the finish chunk). Providers
  that bundle usage onto the last content frame no longer fall back to
  the chars/4 estimate in accounting.
- proxy: cap GitHub Models output tokens (new per-platform maxTokensCap)
  and trim oversized histories before a github dispatch instead of
  burning the hop on a guaranteed 400/413. Attempt-scoped: the next
  candidate still sees the full history.
- ratelimit: throttle the rate_limit_usage sweep to once a minute (it
  ran an unindexed DELETE on every request), stop the in-memory windows
  growing unbounded on a healthy DB (memory is now the degraded-mode
  fallback only), cap 429 cooldown escalation at 10 minutes for routes
  with no published daily limits, and sweep expired cooldown rows once
  at startup.
- router: re-rank legacy-priority chains to dense positions before
  adding 429/fail penalties — spaced priorities (e.g. holes left by
  disabled models) could exceed MAX_PENALTY and make demotion inert.
- error-classify: walk err.cause (bounded, cycle-safe) so wrapped undici
  transport errors (ECONNRESET, EPIPE, socket hang up, UND_ERR_*)
  classify retryable and fail over instead of 502-ing; client/hedge
  aborts stay non-retryable.
- gemini-wire: translate JSON-Schema type unions ("type": ["number",
  "null"]) to Gemini's type + nullable, and inline local $ref targets
  from $defs instead of dropping the whole subschema.
- db-backup: upload Hugging Face backups through the commit API as
  base64 text — the old PUT to /resolve/ never persisted, so HF Spaces
  restored nothing on cold start. Legacy raw blobs still restore.
- providers: Zhipu keys issued on the global z.ai console now validate
  and route via api.z.ai automatically; domestic keys are unaffected.
- proxy lib: pass hostnames through to SOCKS5 proxies unresolved so
  rule-based clients (Clash) can route by domain.
- crypto: maskKey no longer echoes keys of 8 chars or fewer.

Docker:
- drop the VOLUME declaration; persistence comes from the compose named
  volume or an explicit bind mount. The anonymous volume broke PaaS
  builds (Railway, Coolify, Dokploy, CapRover) and shadowed same-path
  bind mounts.

62 new/updated tests across 13 test files; full suite green.

Co-authored-by: Claude Fable 5 <[email protected]>
Stream Playground responses for every model (#942)
Some checks failed
Docker / Merge manifest and push (pull_request) Has been cancelled
CI / Test & build (Node 20) (pull_request) Has been cancelled
CI / Test & build (Node 22) (pull_request) Has been cancelled
Desktop release / Build (ubuntu-latest) (pull_request) Has been cancelled
Desktop release / Build (macos-latest) (pull_request) Has been cancelled
Desktop release / Build (windows-2022) (pull_request) Has been cancelled
Docker / Build (linux/amd64) (pull_request) Has been cancelled
Docker / Build (linux/arm64) (pull_request) Has been cancelled
51f888c653
The Playground only streamed fusion; ordinary chats blocked on the full
response, so long generations looked frozen. Now every send asks for a
stream and renders deltas as they arrive.

- New client/src/lib/playground-stream.ts parses the proxy's SSE framing
  (split frames, CRLF, choice-less usage frame, in-band error frames,
  tool_call deltas skipped, missing [DONE]) with 20 unit tests.
- Reasoning deltas render inside the assistant bubble in FusionTrace's
  visual style, auto-collapsing when the answer starts. The proxy holds
  the preamble until an attempt commits, so reasoning lands together
  with the first token — the trace arrives collapsed and expandable.
- Auto-scroll now follows the stream only while the reader is at the
  bottom; scrolling up detaches it (direction-aware, so our own smooth
  scroll doesn't cancel the follow), and sending or clearing re-engages.
- Metadata parity with the blocking path via X-Routed-Via and
  X-Fallback-Attempts; latency hidden while streaming. Mid-stream errors
  keep the partial answer and add an error bubble. Clear/unmount aborts
  the fetch cleanly.

Verified end to end in headless Chromium against a local gateway and a
mock SSE provider: 25 distinct partial render states during one answer,
reasoning expandable, scroll position held while streaming.

Co-authored-by: Claude Fable 5 <[email protected]>
Playground conversations + post-add model picker (#944)
Some checks failed
Docker / Merge manifest and push (pull_request) Has been cancelled
CI / Test & build (Node 20) (pull_request) Has been cancelled
CI / Test & build (Node 22) (pull_request) Has been cancelled
Desktop release / Build (ubuntu-latest) (pull_request) Has been cancelled
Desktop release / Build (macos-latest) (pull_request) Has been cancelled
Desktop release / Build (windows-2022) (pull_request) Has been cancelled
Docker / Build (linux/amd64) (pull_request) Has been cancelled
Docker / Build (linux/arm64) (pull_request) Has been cancelled
6c4233b684
* Playground conversations + post-add model picker

Two dashboard features:

Persistent Playground conversations. New playground_conversations table
(migration 20260820_000001) and /api/conversations CRUD behind the
dashboard session gate; a collapsible sidebar lists conversations with
auto-titles (first user message, 40 chars) and relative times, with
inline rename and confirm-to-delete. Transcripts save at exchange end
(never per stream delta), including per-message routing meta, reasoning
traces, and error bubbles; Clear starts a new conversation and keeps
the old one. Active conversation id is remembered across reloads.
Transcripts are capped at 2 MB with an honest 413.

Model picker after adding a key. When a newly added provider key's
platform has six or more catalog models, a dialog offers checkboxes to
limit which models the key serves — saved as ONE PATCH to the existing
modelScope field. Everything-selected saves nothing (scope stays null,
so future catalog models keep flowing); Confirm is disabled on an empty
selection because an empty scope would mean "serve everything". Small
catalogs, keyless gateways, and bulk imports skip the dialog. No new
server surface.

24 new i18n keys translated across all 60 locales (check:i18n: 856
keys). Verified in headless Chromium end to end: transcripts survive
reload, switching restores model + system prompt, streaming and
scroll-follow unregressed.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Full-bleed three-column Playground with a settings rail

The Playground becomes three edge-to-edge columns: conversations on the
left, the chat in the middle, and a new collapsible settings rail on the
right holding the model picker, the system prompt, and new sampling
controls (temperature, top_p, max_tokens — sliders/input with per-knob
on/off switches; only enabled knobs are sent, values persist in
localStorage and survive toggling off). Below lg both rails start
collapsed so the chat keeps the width.

Full-bleed is route-scoped: a PageContainer in App.tsx keeps every other
page's container classes byte-identical and lets /playground fill the
viewport under the nav without hardcoded heights.

New client/src/lib/playground-sampling.ts (pure clamp/serialize/request
assembly, 17 tests); ranges mirror the server's zod bounds exactly.
8 new i18n keys translated across all 60 locales (864 total).
Streaming, scroll-follow, conversations, attachments, and fusion are
unregressed — both browser e2e suites pass on the new layout.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Drop the New badge from the fusion picker entry

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
* fix(release): bump desktop version to 0.8.4 so artifacts match the tag (#948)

The v0.8.2, v0.8.3 and v0.8.4 releases all shipped installers stamped
0.8.1 because desktop/package.json was never bumped after v0.8.1
(d8ba550). electron-builder reads that field for the artifact filenames,
so every artifact since is mislabeled.

- desktop/package.json: 0.8.1 -> 0.8.4 (matches the latest release tag)
- desktop/package-lock.json: root version tracked 0.6.9 -> 0.8.4
- new desktop/src/__tests__/version.test.ts: asserts the version is
  x.y.z and that the lockfile root tracks it, so a forgotten bump fails
  the desktop test step in desktop-release.yml instead of shipping a
  mislabeled installer. (Workflow edits are out of scope for this
  account; the test rides on the existing 'Run desktop tests' step.)

Checked: npm --prefix desktop test (vitest) — 6/6 pass.

* fix(release): fail the tagged desktop build when the version misses the tag (#948)

v0.8.4 is already out (2026-08-20) with artifacts stamped 0.8.1, so 0.8.4
is the wrong target: the next tag is v0.8.5. Retarget the bump and add the
guard #948 actually asked for, which the version test alone cannot provide
— only a tagged run knows which version is being released.

- desktop/package.json + package-lock.json: 0.8.4 -> 0.8.5, so the NEXT
  release is the one that comes out labeled correctly.
- desktop-release.yml: new "Verify desktop version matches the tag" step,
  gated on startsWith(github.ref, 'refs/tags/'), placed before the install
  and package steps so a mismatch fails in seconds instead of after a full
  three-platform build. Untagged workflow_dispatch/pull_request runs skip it.
- desktop-release.yml: install desktop deps with `npm ci` instead of
  `npm install`. install rewrites desktop/package-lock.json in place, which
  quietly repaired a stale lockfile version before the lockfile-sync
  assertion in version.test.ts could ever see it; ci installs the lockfile
  as committed, making that assertion a real guard in CI.
- version.test.ts: comment now points at the workflow step as the
  authoritative check and records why the lockfile assertion needs `npm ci`.

Checked: npm --prefix desktop ci (clean, lockfile untouched); npm --prefix
desktop test — 6/6 pass in 2 files; workflow parses as YAML and the guard
script was run locally against matching and mismatching GITHUB_REF_NAME
values (exit 0 / exit 1 with an ::error annotation).

Not covered here: the already-published v0.8.2–v0.8.4 releases still carry
mislabeled installers and latest*.yml updater manifests. Correcting those
assets is manual release ops on GitHub, not a repo change.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: tashdroid <[email protected]>
Co-authored-by: Tash <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
* fix(desktop): restore saved outbound proxy settings on app start (#949)

The standalone server hydrates its proxy state in index.ts right after
initDb, but the desktop embedder (desktop/src/server-host.ts) builds the
app without index.ts and never did — so a proxy URL saved through
PUT /api/settings/proxy sat in the settings table while the process
started with an empty proxy, and every restart made the Outbound proxy
fields appear empty until re-saved.

- lib/proxy.ts: new restoreProxySettings() — the single hydration step
  (proxy_url / proxy_enabled / proxy_bypass), env-var precedence intact
- index.ts: calls it instead of the three inline apply* calls
- server-host.ts: calls it after initDb, before createApp
- new proxy-restore.test.ts: pins DB → process-state hydration, the
  disabled flag, empty-DB defaults, and PROXY_URL env precedence

Checked: server vitest (proxy + proxy-restore: 87 pass; one pre-existing
compression perf failure on clean main, unrelated), desktop vitest,
esbuild bundle of server-host.ts.

* test(desktop): pin the proxy hydration call in the desktop boot path (#949)

The fix in a78e2c4 was one line in desktop/src/server-host.ts, and nothing
tested it: proxy-restore.test.ts proves restoreProxySettings() works, but
deleting the call from the embedder left the whole suite green — exactly
the shape of the original regression.

- new desktop/src/__tests__/server-host-boot.test.ts: mocks the entire
  server surface server-host.ts imports and records the boot sequence, so
  the call is pinned along with its position (after initDb, before
  createApp/listen). Verified it fails when the call is removed.
- proxy-restore.test.ts: cover the tiers the previous test skipped — the
  ALL_PROXY → HTTPS_PROXY → HTTP_PROXY fallback order, the lower-case
  spellings, the dashboard value still outranking the ambient env, and
  NO_PROXY being parsed and re-parsed at restore time.

Checked: desktop vitest 8 pass (2 files); server vitest 2516 pass /
5 skipped (224 files); tsc --noEmit clean for server, and for desktop
apart from two pre-existing server/src/services/media.ts errors.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: tashdroid <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
* Analytics: give custom endpoints their own identity (#889)

Every custom OpenAI-compatible endpoint shares the platform id 'custom', so
/by-platform collapsed all of them into one row and the operator could not
tell which relay did what. Group the provider breakdown by the serving key's
base_url (the canonical endpoint identity — custom-endpoint.ts pools
credentials by base_url) and return a stable providerId plus a display
endpoint host.

- /by-platform: GROUP BY platform + COALESCE(base_url,''); per-group p95 now
  scoped to the endpoint; rows carry providerId + endpoint.
- /requests: new 'provider' filter (platform slug or custom:<base_url>);
  legacy 'platform' still works. Both bound, never interpolated.
- Client: charts/table/dropdown render the endpoint host and filter by
  providerId.

Regression tests prove the split, p95 scoping and filter; they fail on the
old code.

* Analytics: split the remaining views by custom endpoint (#889)

/by-platform learned endpoint identity, but the issue names three more views
that still read every custom relay as one "custom": recent errors, the
per-model breakdown and the error distribution. Same treatment for those, plus
two holes the first pass left.

- /errors and /error-distribution byPlatform: LEFT JOIN api_keys and carry
  providerId + endpoint, so a failure names the relay that produced it instead
  of a bare 'custom' the operator cannot act on.
- /by-model: group by (platform, endpoint, model_id), so one model id served by
  two relays is two rows whose latency describes an actual endpoint. The models
  join is endpoint-scoped for the same reason — `models` is unique on
  (platform, model_id, endpoint_scope) since #651, so joining on
  (platform, model_id) alone matched one row per relay that registered the
  model and multiplied every count by that many.
- providerDisplayName: the host alone collides when one gateway fronts several
  endpoints (gw.example.com/tenant-a/v1 and /tenant-b/v1 both read as
  'gw.example.com'), which is the same collision one level down. Append the
  path when it carries identity; a bare '/v1' endpoint still reads as its host.
- /requests: the BARE 'custom' provider id is the orphan bucket — /by-platform
  emits it only for rows whose key is gone — but the filter fell through to the
  slug branch and returned every relay's traffic, contradicting the count on
  the row that was clicked. It now selects the orphans it stands for.
- Endpoint identity is normalized in SQL exactly as endpoint-scope's
  normalizeBaseUrl normalizes in JS, so a base_url stored with a trailing slash
  before keys.ts normalized on write is not a second endpoint.
- Client: recent errors, the per-model table and the errors-by-provider chart
  render the endpoint name.

Regression tests for each; the seven new route tests fail on the previous
commit.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: tashdroid <[email protected]>
Co-authored-by: Tash <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
* fix(proxy): never route loopback destinations through the proxy (#951)

The issue reports socks5h being treated as socks5, but the SOCKS path
already sends the destination domain to the proxy for every scheme (the
socksHostnameLookup hook from #630), so the h suffix was never dropped.

The real cause is in the Tor log the reporter included: the app was
giving Tor an IP address to port 11434 — Ollama on 127.0.0.1. Loopback
destinations were being routed through the proxy at all. A proxy cannot
reach your own 127.0.0.1, and because it is an IP literal the SOCKS
agent must send it as ATYP 0x01 (an IP) no matter what the socks5h
suffix promises — exactly what triggers Tor's 'giving Tor only an IP
address' warning and the connection refusal.

shouldBypassProxy now treats loopback (127.0.0.0/8, ::1, 0.0.0.0) and
localhost as direct, on both the global and per-key proxy paths. Public
destinations are unaffected.

* fix(proxy): extend the direct-route bypass to LAN destinations (#951)

The loopback bypass fixed 127.0.0.1, but the same failure hits the other
half of the documented local use case: url-guard's own policy note says
this app exists to point at llama.cpp / Ollama / LM Studio "on localhost
or the LAN", and a remote proxy has no route to 192.168.1.20 either —
while the IP literal still makes Tor log "giving Tor only an IP address"
and refuse the connection.

shouldBypassProxy now routes loopback AND private/LAN destinations
(RFC1918, ULA, CGNAT) direct, by reusing url-guard's classification
instead of a second copy of it: isLoopbackOrPrivateUrl grows a hostname
half, isLoopbackOrPrivateHostname, which the proxy router calls with the
hostname it already parsed for NO_PROXY. That also picks up the
trailing-dot FQDN spelling (`localhost.`), which resolves like the bare
name but matched neither `=== 'localhost'` nor `.endsWith('.localhost')`
and was being proxied as if it were a public host. The URL is now parsed
once for both checks rather than twice in two try/catch blocks.

FREEAPI_PROXY_LOCAL_DESTINATIONS=true opts back in, for the one setup
where proxying a local address is the point: an `ssh -D` dynamic tunnel,
where http://127.0.0.1:11434 through the SOCKS proxy is meant to reach
the REMOTE host's Ollama. Documented in .env.example next to NO_PROXY.

Tests cover ::1 (bare and bracketed), 127.0.0.2, 0.0.0.0, a *.localhost
subdomain, `localhost.`, 192.168.1.20 / 10.0.0.5 / 172.16.4.2, an IPv6
ULA, the opt-out env var in both directions, and a public host still
riding the proxy. clearProxyEnv also clears
FREEAPI_BLOCK_PRIVATE_PROVIDER_URLS, which an operator machine may
export — the local cases use platform 'custom', so it re-runs the SSRF
guard and failed them for the wrong reason.

---------

Co-authored-by: tashdroid <[email protected]>
Credits the author of the proxy loopback/LAN bypass (#963), proxy
settings persistence (#962), desktop version alignment (#961), and
the analytics chart-label and custom-endpoint identity fixes
(#959, #958).

Co-authored-by: Tash <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
Analytics: show every provider label on the category charts (#890) (#959)
Some checks failed
Docker / Build (linux/arm64) (pull_request) Has been cancelled
Docker / Merge manifest and push (pull_request) Has been cancelled
CI / Test & build (Node 20) (pull_request) Has been cancelled
CI / Test & build (Node 22) (pull_request) Has been cancelled
Desktop release / Build (ubuntu-latest) (pull_request) Has been cancelled
Desktop release / Build (macos-latest) (pull_request) Has been cancelled
Desktop release / Build (windows-2022) (pull_request) Has been cancelled
Docker / Build (linux/amd64) (pull_request) Has been cancelled
6210c08741
* Analytics: show every provider label on the category charts (#890)

recharts' default interval is 'preserveStartEnd': when more providers than
fit in the width, it silently drops the middle ticks, so only a few provider
names render, the rest are hover-only, and a bar no longer lines up with the
label the user can see (a bar with no label reads as a different provider).

Force every tick (interval=0) and rotate + truncate the labels so they fit,
via a small pure helper (lib/chart-axis.ts) applied to the five category
charts: requests by provider/agent, latency by provider, TTFT by provider and
errors by provider. The timeline charts are time series and are untouched.

- client/src/lib/chart-axis.ts: categoryAxisProps() + truncateAxisLabel
- client/src/pages/AnalyticsPage.tsx: spread the props on the 5 category X-axes
- client/src/lib/chart-axis.test.ts: regression tests for the helper

* Analytics: cover the errors-by-category axis and stop the tilted labels colliding (#890)

Follow-ups to the first pass at #890:

- "Errors by category" is a layout="vertical" chart, so its category axis is
  the YAxis. It was left on recharts' default interval and dropped labels the
  same way the vertical charts did. Added verticalCategoryAxisProps() — every
  tick, plus the 128px gutter and a truncation cap sized to it.

- categoryAxisProps() was a fixed -30 degrees at height 56, which forced all
  the ticks on and then let them overlap on a crowded or narrow (single-column
  mobile) chart, and under-reserved the strip an 18-char label needs. It now
  takes the category count and picks the shallowest tilt that still seats them
  on a 280px plot (-30, -45, -90), with the height derived from the tilt and
  the truncation cap instead of hard-coded.

- Callers pass their data.length.

- Added a render test that mounts a real recharts BarChart under jsdom and
  counts the tick <text> elements, so the props are proven to reach recharts
  rather than merely to have the right shape. Stubs getBoundingClientRect,
  which recharts uses to measure labels and jsdom answers with zeroes.

- The comment claimed recharts defaults to 'preserveStartEnd'; both
  implicitXAxis and implicitYAxis default to 'preserveEnd'.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Fix the client test job on Node 20: pin jsdom to 27 and declare it at the root

The render test added in the previous commit brought in jsdom@30, which broke
"Test & build (Node 20)" — 177 tests passed but vitest exited nonzero on an
unhandled module-load error:

    TypeError: webidl.util.markAsUncloneable is not a function
      at new CacheStorage node_modules/undici/lib/web/cache/cachestorage.js:20

Two separate problems, both fixed here.

1. jsdom 28+ depends on undici, and jsdom 30 wants undici ^8.9.0. undici 8
   declares `engines: >=22.19.0` and does

       const { markAsUncloneable } = require('node:worker_threads')
       webidl.util.markAsUncloneable = markAsUncloneable

   with no guard. worker_threads.markAsUncloneable landed in Node 22.10, so on
   Node 20 that assigns undefined and the first call throws. (undici 6, which
   the server pins, writes `markAsUncloneable || (() => {})`; undici 7
   feature-detects. Only 8 assumes it.) jsdom 30's own engines field says
   `^22.22.2 || ^24.15.0 || >=26.0.0` — it never supported the Node 20 this
   repo declares in `engines` and tests in CI; nothing enforced that.

   jsdom is now pinned to ^27, the last major with no undici dependency at
   all, so the failure mode cannot come back through a transitive bump.
   `undici` is now absent from the root tree entirely — the only copy left is
   the server's own direct 6.26.0.

2. jsdom was declared in client/package.json, and npm nested it at
   client/node_modules/jsdom rather than hoisting it. vitest hoists to the
   root node_modules and resolves an environment package relative to itself,
   so it never looks in client/node_modules — the run died with "Cannot find
   package 'jsdom'", i.e. the same 177-pass/nonzero-exit symptom for a
   different reason. Whether npm hoists or nests depends on how the rest of
   the tree deduplicates, which is why this differed between Node versions.

   Declaring it in the root package.json puts it beside vitest deterministically.

Verified every package in the installed tree now accepts Node 20.19/20.20;
`npm ci` reproduces the tree from the lockfile; full `npm test` is green
(server 2499, cli 95, client 182).

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: tashdroid <[email protected]>
Co-authored-by: Tash <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
epistemophiliac closed this pull request 2026-08-25 10:38:55 +00:00
Some checks failed
Docker / Build (linux/arm64) (pull_request) Has been cancelled
Docker / Merge manifest and push (pull_request) Has been cancelled
CI / Test & build (Node 20) (pull_request) Has been cancelled
CI / Test & build (Node 22) (pull_request) Has been cancelled
Desktop release / Build (ubuntu-latest) (pull_request) Has been cancelled
Desktop release / Build (macos-latest) (pull_request) Has been cancelled
Desktop release / Build (windows-2022) (pull_request) Has been cancelled
Docker / Build (linux/amd64) (pull_request) Has been cancelled

Pull request closed

Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
epistemophiliac/LLMAPI!6
No description provided.