chore(sync): upstream v0.8.7 — desktop boot parity, full-width pages, budget legend by provider pool #8

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

Upstream release v0.8.7 (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.7 (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]>
The add-key dialog's provider picker (ModelCombobox) renders each option
label with 'truncate' and no title fallback, so long labels like
'OVH AI Endpoints (no key needed)' clip to 'OVH AI Endpoints (no key ne…'
and the full text is unreachable. The trigger label has the same problem.

Add a title attribute to both the trigger and the list option so the full
name is reachable on hover — matching the codebase's existing convention for
truncated text (e.g. baseUrl in provider-list.tsx). No layout change; the
truncation stays (it's correct for a fixed-width picker), the text just
becomes inspectable.

Co-authored-by: tashdroid <[email protected]>
* fix(ui): clarify the capability-tier tooltip (#891)

The 'Frontier/Large/Medium/Small drive the intelligence axis' tooltip was
ambiguous: 'drive' didn't say the tiers take precedence over the rank, and
'None (unscored)' read as either 'no effect' or 'zero score' (the latter is
the actual behaviour — an unrecognized tier scores 0 and never wins an
auto-route, per scoring.ts tierValue).

Reword the hint to state the real rule (higher tier outranks lower; within a
tier the intelligence rank breaks ties; no tier => 0 => never auto-routed)
and make the placeholder say what it does. Updated the six human-reviewed
locales (en, zh-CN, es, fr, pt-BR, it); the machine-translated locales
regenerate on the next pass.

Checked: client check:i18n (60 locales, 864 keys) passes; client vitest 177/177
pass. The one jsdom unhandled-error is a pre-existing incomplete node_modules
install, identical on clean main.

* fix(i18n): the capability tier is not a routing veto (#891)

The reworded tooltip traded one ambiguity for a false statement: it told the
user a model with no recognized tier "scores 0 and never wins an auto-route".
It does not. tierValue floors an unknown label at 0 on the INTELLIGENCE axis
only, and that axis is one weighted term of combineScore's convex combination
— 0.25 under the default balanced preset, against 0.5 reliability and 0.25
speed. There is no exclusion anywhere in the router, and an untiered model
with good reliability and speed outscores a Frontier one under three of the
four presets.

The claim was lifted from a stale comment above TIER_VALUE describing a
behaviour that was deliberately removed: custom models are seeded at the
catalog MEDIAN tier precisely so they ARE routable (custom-model-seed.ts,
#488). That comment and the matching one on ModelDetailPage are corrected too,
so the next contributor does not read the same thing back out of the source.

The hint now states only what the code does: tier beats tier on the
intelligence axis, the in-tier rank still moves the score (not a tiebreak
— see #673), and no tier means the floor of that one axis, not a veto.
sizeLabelNone follows it.

All 60 locales are updated, not the six the first pass touched. There is no
translation pipeline here — locale files are authored directly, one key at a
time, per docs/translating.md — so each is a real translation reusing the
terminology that file already uses for the adjacent capability-tier and
intelligence-rank fields. zh-CN and zh-TW keep full-width punctuation, spaced
Latin, and —— rather than a spaced em dash (#748).

capability-tier-hint.test.ts holds the invariant that the 6-of-60 gap broke,
following model-limit-hints.test.ts (#870): both strings present, distinct,
and never byte-identical to en.json outside en itself. Staleness is caught by
the score claim itself — every pre-#891 translation quoted a literal "0" and
no correct one needs a number — so a locale left behind on the next reword
fails rather than shipping.

Checked: client check:i18n (60 locales, 864 keys), client vitest 185/185
across 17 files, tsc -b client, server tsc --noEmit, and the server scoring
suites (33 tests) since the scoring.ts change is comment-only.

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

---------

Co-authored-by: tashdroid <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
* chore: ignore TypeScript incremental build cache (*.tsbuildinfo)

The server build writes server/tsconfig.tsbuildinfo into the repo root;
git status was showing it as untracked noise.

* feat(custom): keep user-deleted custom models deleted across scheduled sync (#926)

Catalog-managed models carry a tombstone so catalog-sync never resurrects a
model the user deleted. The scheduled custom-model sync had no equivalent, so
deleting a custom relay's model (or its whole endpoint key) was undone by the
next sync pass: the sync only checks whether the model id is still present in
the `models` tableerial and treats a deletion as "new" again.

- custom-model-tombstone.ts: record/is/clear helpers keyed by
  (endpoint_scope, model_id) — the exact identity a custom model row has today
  (#651), so deleting a model on one relay never suppresses the identical id
  on another.
- migration 20260819_000001_custom_model_tombstones: add the table (reversible).
- custom-model-sync.ts: skip tombstoned models during the scheduled pass and
  report them in a new `tombstoned` counter.
- custom-model-register.ts: an EXPLICIT re-registration clears the tombstone,
  so a human re-adding the model lifts the deletion.
- routes/keys.ts + routes/models.ts: record a tombstone when a custom model or
  its owning key is deleted.
- tests: migration round-trip + sync behavior (no resurrection, per-relay
  scoping, explicit re-registration lifts the tombstone).

Co-Authored-By: AtomCode (deepseek-v4-flash) <[email protected]>

* fix: resolve merge conflict markers in defaults.ts and roundtrip.test.ts

Co-Authored-By: AtomCode (LongCat-2.0) <[email protected]>

* fix(migrations): register custom-model-tombstones migration in filename order (#926)

* test(migrations): sync roundtrip expectation to tombstone-before-playground order (#926)

---------

Co-authored-by: Asuan <[email protected]>
Co-authored-by: AtomCode (deepseek-v4-flash) <[email protected]>
Free key, no card; free models carry a :free suffix with a per-minute rate
limit. Live-probed 2026-08-23: key validation via /v1/models rejects a wrong
key with 401; a burst across many :free models trips an account-wide 429,
so provider-quota pools the platform as one allowance.

From #968 by @tashdroid, with comment corrections.
* fix(router): context-window safety margin against chars/4 under-counting

The routing estimate is chars/4, which under-counts dense payloads (JSON,
code, CJK) by up to ~2x: oversized requests were routed to models whose
real tokenizer count exceeded the window and got a mid-chain 400 (kilo:
277k-523k real tokens past a 256k window). fitsContextWindow() now applies
a 1.25 factor on every context filter, exempting platforms whose
pre-dispatch trim guard already guarantees the fit (github).

* fix(router): make the context-window safety margin a soft preference (#956 review)

/v1/models advertises the raw window, so clients legitimately pack
requests right up to it — excluding margin-violating models outright
turned an already-handled upstream 400 into 'all models exhausted'.

- routeRequest: sweep margin-fitting models first; raw-window fits stay
  eligible behind them. The loop guard is strict again and the diag line
  now emits the effective number (context X < estimated Y x1.25 = Z),
  keeping '< estimated' so exhaustion bucketing still works.
- getOrderedFusionChain: re-run the servable filter with the strict
  comparison when nothing survives the margin pass.
- routePinnedModel: strict comparison only — a pinned slot has no
  substitute, attempting beats refusing.
- The exact routingReserveTokens(max_tokens) reserve is threaded through
  routeRequest/fitsContextWindow and added unscaled instead of being
  inflated by the factor.
- Test: assert which model serves each estimate instead of toThrow (the
  old assertion only passed because no seeded groq model exceeds 132500).
* perf(compression): early-exit protected-span check in the per-line hot path

scanProtectedSpans() collects every match, sorts and merges the spans, but
the per-line callers (toolfilter mustKeep, protectedLines, hard-budget,
relevance) only need a yes/no answer. On a 20k-line adversarial payload that
collect+sort+merge is paid once per line per stage.

Add hasProtectedSpan(): same rule sequence, exits on the first hit, no span
list built. Verified equivalent to scanProtectedSpans(text).length > 0 across
5000 repeated calls (no global-regex lastIndex drift). This is the difference
between the compression regression guard sitting under and over its 250ms
budget on slow hardware (the Pi this runs on): the 20k-line trace case drops
from ~298ms to ~212ms warm.

* test(compression): pin hasProtectedSpan to scanProtectedSpans

The new early-exit helper is a drop-in boolean for
scanProtectedSpans(text).length > 0. Lock that contract with a test that
interleaves the two APIs across a corpus of protected and unprotected
lines (including global-flag rule patterns), so a future regex change
that makes .test() stateful or diverges the boolean from the span count
fails loudly instead of silently changing what the tool filter protects.

Co-Authored-By: tashdroid <[email protected]>

---------

Co-authored-by: tashdroid <[email protected]>
* fix(media): serve transcription usage at /api/media/usage

The endpoint's modality enum only allowed 'image' and 'audio', so a
request for 'transcription' 400'd even though the data model fully
supports it: STT models live in media_models with
modality='transcription' and their requests are logged with
request_type='transcription' (logMedia writes request_type from the
row's modality). The SQL was already parameterized by modality, so the
fix is the enum plus a comment.

The client's Audio tab already renders an STT section (models + detail
page) but with no usage stats, because the usage query could never
fetch transcription. Wire it up: fetch modality=transcription for the
STT section and show the same UsageSummaryCard the TTS section uses.

Adds a regression test pinning that transcription usage is served and
that TTS requests for the same model_id are not counted.

* Only render the STT usage card when there are transcription rows, matching the sibling cards

---------

Co-authored-by: tashdroid <[email protected]>
Co-authored-by: t <t@t>
Co-authored-by: Tashfeen <[email protected]>
* fix(media): serve transcription usage at /api/media/usage

The endpoint's modality enum only allowed 'image' and 'audio', so a
request for 'transcription' 400'd even though the data model fully
supports it: STT models live in media_models with
modality='transcription' and their requests are logged with
request_type='transcription' (logMedia writes request_type from the
row's modality). The SQL was already parameterized by modality, so the
fix is the enum plus a comment.

The client's Audio tab already renders an STT section (models + detail
page) but with no usage stats, because the usage query could never
fetch transcription. Wire it up: fetch modality=transcription for the
STT section and show the same UsageSummaryCard the TTS section uses.

Adds a regression test pinning that transcription usage is served and
that TTS requests for the same model_id are not counted.

* fix(media): allow registering custom speech-to-text models

The custom media upsert (/api/media/custom) restricted modality to
'image' and 'audio', so a custom STT endpoint could not be registered
even though the data model, the /v1/audio/transcriptions handler and
the usage endpoint all support 'transcription'. Same enum gap as the
usage endpoint; the SQL and priority logic were already modality-
agnostic.

Adds a regression test registering a transcription model and
asserting it lands under the endpoint key.

* Only render the STT usage card when there are transcription rows, matching the sibling cards

* Make custom speech-to-text endpoints work end to end

Registration alone left a custom STT row unreachable: runTranscription
dropped the row's key_id so getProviderCredential skipped platform custom,
and callTranscriptionProvider had no custom adapter. Thread key_id through
SttCandidate, add a custom adapter posting multipart to
{baseUrl}/audio/transcriptions, add transcription to ApiKeyModel.kind, the
Keys page kind labels and sort order, and the custom-type picker, with the
two new i18n keys in all 60 locales. Tests cover the full path against a
mocked upstream.

---------

Co-authored-by: tashdroid <[email protected]>
Co-authored-by: t <t@t>
Co-authored-by: Tashfeen <[email protected]>
xkiro.com is a real OpenAI-compatible gateway (https://xkiro.com/v1) that
answers /v1/models and /v1/chat/completions. Registered it like the other
aggregators (OrcaRouter, SEA-LION, NaraRouter): shared Platform type, key
prefix detection (XKIRO_), auth.json provider name (xkiro), the keys-route
PLATFORMS list, an account-level shared quota pool (xkiro::free), the client
provider picker, and a platform color.

One non-obvious wiring decision, live-probed 2026-08-22: GET /v1/models
answers 200 with NO key (public catalog), so the default /v1/models key
validation would be a false positive — it would mark a garbage key valid.
validateUrl therefore points at /v1/usage, which 401s on a missing or invalid
ClientApiKey ("Invalid or disabled ClientApiKey"), so a bad key is rejected
the way the ModelScope and Pollinations adapters handle their public /models
endpoints.

Catalog rows (the model ids named in #947) are authored in the hosted catalog
repo, not here — the ids are unverified against a live account, and a bad id
is caught by the health check there rather than shipped as a default. This PR
is the gateway-side registration only.

Checked: server vitest key-parser + openai-compat 77/77 pass (xkiro added to
the platform-instances list and the prefix-detection test); client vitest
177/177 pass; client eslint clean (the 7 react-refresh/only-export-components
errors in shared.tsx are pre-existing, identical on clean main). The one
server compression.test.ts timing failure and the client jsdom
missing-package error are both pre-existing on clean main (verified by
stashing and re-running).

Co-authored-by: tashdroid <[email protected]>
Co-authored-by: Tashfeen <[email protected]>
Debugging failover meant shell access and docker logs. The dashboard now
has a Logs page (Analytics gains the same chevron menu as Models, listing
Analytics and Logs).

Server: the existing installLogRedaction console wrapper gains a tap, so
every line is redacted once and recorded once — no second console patch.
A structured providerLog(level, message, {provider, model, event,
requestId}) channel replaces the console lines for key auto-disable /
key invalid (health), cooldown recovery (cooldown-probe), and model
retirement, so those render as filterable chips rather than grep-bait.
Entries live in a 1000-entry ring (all levels); warn/error also persist
to a new server_logs table (migration 20260823_000001) with retention
folded into the existing prune cycle (SERVER_LOGS_RETENTION_DAYS=7,
SERVER_LOGS_MAX_ROWS=50000, 0 disables). Ids are monotonic and stable
across restarts; the newest 200 persisted rows preload into the ring at
boot. GET /api/logs (levels CSV, q, provider, sinceId cursor, limit,
ring-wide counts) and POST /api/logs/clear sit behind the dashboard
session like every other /api route. The viewer's own polling lines are
dropped at ingest. The 32+ char redaction catch-all gains a low-entropy
guard so a run of one repeated character is no longer swallowed.

Client: Logs page with multi-toggle level pills (server-side filtering,
ring-wide counts), provider select, debounced search, incremental
sinceId polling into a 2000-entry buffer, direction-aware live tail
with a jump-to-latest pill, visibility-gated auto-refresh with Pause,
click-to-expand long messages, and confirm-to-clear. The /models nav
split control is generalized to a navMenus lookup now shared by
/analytics.

24 new i18n keys translated across all 60 locales (890 total).
26 server route tests + 26 client unit tests; verified in headless
Chromium end to end (chevron nav, live entries from streamed traffic,
error-only filtering, no-match search, clear), streaming and
conversations e2e suites unregressed, full suite green.

Co-authored-by: Claude Fable 5 <[email protected]>
Bump desktop to 0.8.6 so the release artifacts match the v0.8.6 tag (#994)
Some checks failed
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
Docker / Merge manifest and push (pull_request) Has been cancelled
ff8f9ad94c
* feat(cli): setup-dsh — configure DeepSeek Harness in one command

DeepSeek Harness (dsh) keeps every provider as a route under
`llm-pi-ai.providers` in $DSH_HOME/settings.yaml. A route its installed
catalog does not ship must spell out api, baseURL, and a non-empty models
list, which is exactly what a gateway with a per-user live catalog is.

`setup-dsh` writes that route from the live /v1/models roster, makes
freellmapi/auto the default model, and drops FREELLMAPI_API_KEY into
$DSH_HOME/.env (0600) — the environment layer dsh reads on its own, so the
route works on the next request with nothing exported. --profile adds a
second route instead of taking over the default; --model pins the default;
DSH_HOME is honoured.

The settings document is shared with every other provider the user has,
so the marked-block YAML merge (which drops whole top-level keys it
re-emits) would have deleted them. GeneratedFile now takes a structured
`value` for YAML too: a deep merge over the parsed document that keeps
sibling routes, comments, key order, and quoting, and where `undefined`
deletes a key — needed to clear a previous default's reasoningEffort,
which dsh would otherwise reject on every request. Adds `yaml` as the CLI's
second runtime dependency.

Also: dsh traffic is attributed on the dashboard (its user-agent is
`deepseek-harness/<v> (+url)`), the Agents page gets a tile, and the docs
list the new command.

* docs: add DeepSeek Harness to the README agent grid
Requests that spell out top_p: 1, n: 1 or zero penalties now share a cache entry with requests that omit them. Cache key version bumped to v4.
Probe latency is measured on the ping alone, the supports_tools write-back is scoped to the endpoint's keys, probe failures report unknown instead of no, and the results are shown in the probe toast. Refs #874.
Backups service, routes, migration and a collapsed Backups section under Keys. Auth tables are excluded from dumps, restore checks schema and encryption key fingerprint and runs in one transaction with a pre-restore snapshot. Extracted from #879.

Co-authored-by: kentpan <[email protected]>
Pool budgets are deduplicated per quota pool, scaled by usable key count and joined with token-metric quota observations. Rendered as a collapsed section under the existing budget bar. Refs #905.
The Anthropic surface now resolves a pinned model id through the model-group chain like the other surfaces, so a provider failure moves to the same model elsewhere instead of an unrelated model. Gap identified by stephenzwj in #932.

Co-authored-by: stephenzwj <[email protected]>
Video modality via a videoModels catalog key, POST /v1/videos/generations, Pollinations and Hugging Face adapters, dashboard Video tab. Review fixes: provider-default clip duration, url-guard on result downloads, 401 no longer forwarded as a client error, client abort stops the chain.
Off by default. Configurable window and IANA timezone; shifts balanced presets toward reliability during the window, leaves fastest, reliable, custom and priority alone, and labels adjusted weights in the dashboard. Refs #760.
The badge reports the eligible key with the most headroom, computed in one grouped scan, fetched once per page. Group badges reflect the best member. Refs #876.
New key_selection_strategy setting (auto or least-remaining), independent of the routing strategy so the model-ranking bandit stays on. Prefers the key with the most remaining quota, keeps every router gate and the round-robin tiebreak, skips account-scoped pools, and reads quota headroom through a cached platform-filtered query. Refs #919.
GET/PUT /api/settings/headroom exposes rampStart and floor. Thresholds are read once per chain instead of per model. Refs #899.
Snapshots the api key label into request_attempts and shows it as a tooltip on the ladder's key badge. Label only, never the id or credential. Closes #869.
Scoring now ramps down on rpm/rpd/tpm/tpd window utilisation with the same tunable thresholds as the monthly guardrail, backed by a cached single-scan snapshot. Closes #899.
Desktop app tees console output to userData/logs/freeapi.log with rotation and a tray shortcut, so the password reset code is finally visible to desktop users. Docs answer reset, logs and uninstall. Closes #824.
Every custom routing profile now shows up in GET /v1/models as auto:<name> with its max context and availability, and the plain OpenAI-shaped listing also carries the Claude family discovery ids. Refs #895 #880 #960.
Adds Xiaomi MiMo Code with a generator verified against the live schema, and a short localized description on every agent card. Closes #800. Closes #801.
Collapsed accordion for creating, renaming and deleting named fallback chains, showing the auto:<name> id clients send. Refs #895 #960.
Chains can start empty, a new auto_include_new_models flag keeps catalog sync from refilling curated chains, and the routing table gets enable all and disable all. Closes #895.
Closes #887. Closes #876. Closes #874.
7.4 billion tokens a month, 34 providers, 635 free endpoints, 474 model families, regenerated free-tier card, honest 30 day trail disclosure.
Bring the desktop boot sequence up to server parity and bump to 0.8.7 (#1017)
Some checks failed
Docker / Build (linux/amd64) (pull_request) Has been cancelled
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
4774cf02a4
The desktop embedder repeats server/src/index.ts's startup by hand and had
drifted eight steps behind: scheduled backups never fired, the dashboard log
viewer stayed near-empty, the desktop log file shipped unredacted, and the
process safety net and post-sleep recovery never installed. The boot test now
derives the required steps from the index.ts source instead of freezing a
literal list, the build-identity define is mirrored into the real process.env
so the packaged update check works, and the tray gains Open Backups Folder.
epistemophiliac closed this pull request 2026-08-25 10:38:55 +00:00
Some checks failed
Docker / Build (linux/amd64) (pull_request) Has been cancelled
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

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!8
No description provided.