chore(sync): upstream v0.8.4 — Playground conversations + post-add model picker #5

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

Upstream release v0.8.4 (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.4 (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]>
epistemophiliac closed this pull request 2026-08-25 10:38:55 +00:00
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

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