Compare commits

...
16 Commits
Author SHA1 Message Date
Povilas Kirna 7c7ac1021a ci: enforce issue/PR description completeness for template-bypassing submissions (#1959)
* ci: add issue/PR description completeness checks (#1958)

Two github-script workflows that validate description structure on
issue/PR open/edit/reopen, for submissions that bypass the browser
template (API, gh CLI, agent bulk PRs).

- PR check: Summary, Linked Issue, Type of Change, duplicate-search
  box, How to Test.
- Issue check: body length + per-label bug/enhancement fields, plus a
  bug+enhancement conflict guard.
- Pass deletes any prior bot comment and applies `ready for review`;
  fail posts an in-place comment, fails the check, and applies
  `needs work` (PRs) / `needs more info` (issues).
- References existing labels only — never creates or recolours repo
  labels (checks existence first, warns and skips if absent).
- Safe pull_request_target: checkout pinned to the base ref, sparse
  `.github/scripts` only; PR head never checked out.

Closes #1958
Co-authored-by: Povilas Kirna <povilas.kirna@pebble.net>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:58:10 +02:00
Glenn b5590fd008 feat: add placeholder option for dropdowns in issue templates (#2068) 2026-06-03 16:33:26 +02:00
pewdiepie-archdaemon 67b63e9844 Revert "fix(ui): allow manual prompt bar resize (#1201)"
This reverts commit 258e6fc0d4.
2026-06-03 23:04:28 +09:00
pewdiepie-archdaemon 1f6c5ac66b Revert "Codex Agent integration: HTTP surface + plugin bundle + Settings UI"
This reverts commit 8c2705b42a.
2026-06-03 22:47:00 +09:00
pewdiepie-archdaemon 6861c41580 Reapply "Merge branch 'main' of github.com:pewdiepie-archdaemon/odysseus"
This reverts commit cc8fe2f6e3.
2026-06-03 22:47:00 +09:00
pewdiepie-archdaemon cc8fe2f6e3 Revert "Merge branch 'main' of github.com:pewdiepie-archdaemon/odysseus"
This reverts commit 8161c1253d, reversing
changes made to 8c2705b42a.
2026-06-03 22:46:19 +09:00
pewdiepie-archdaemon 8161c1253d Merge branch 'main' of github.com:pewdiepie-archdaemon/odysseus 2026-06-03 22:39:33 +09:00
pewdiepie-archdaemon 8c2705b42a Codex Agent integration: HTTP surface + plugin bundle + Settings UI
This persists work that had been living only in the cookbook docker
container's writable layer — never committed to the host source. Brought
back to git intact, app.py registration re-applied surgically on top of
current main (not the older container copy, which would have regressed
the Windows MIME fix, asynccontextmanager lifespan, and webhook auth
exempts).

routes/codex_routes.py (new):
- GET  /api/codex/capabilities  — what this Odysseus exposes.
- GET  /api/codex/plugin.zip    — downloads integrations/codex as a zip.
- GET  /api/codex/todos         — scope-gated todos:read|write.
- POST /api/codex/todos         — scope-gated todos:write.
- GET  /api/codex/emails        — scope-gated email:read|draft|send.
- GET  /api/codex/emails/{uid}  — single-message fetch.
- _scope_owner() enforces api_token scopes before touching user data.

routes/api_token_routes.py (+103 lines):
- Adds Codex-token-specific issuance + revocation paths.

integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip):
- README.md                       — install instructions.
- .codex-plugin/plugin.json       — Codex plugin manifest.
- scripts/odysseus_api.py         — Python client used by the skill.
- skills/odysseus/SKILL.md        — Codex skill definition.

static/js/settings.js (+253 lines):
- New "Codex Agent" option in the Integrations dropdown.
- Add / edit panel with plugin-bundle download link + curl-with-token
  install instructions per agent.

app.py:
- 7-line surgical change: capture email_router = setup_email_routes()
  and register setup_codex_routes(email_router=email_router) after the
  email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
Alexandre Teixeira b1a4ed13b0 Harden API-token chat endpoint selection
Validate only token-supplied direct base_url values for API-token chat requests, while keeping admin-configured endpoints available for local/LAN providers.

Scope configured endpoint fallback selection to the API token owner, fail closed for unknown token owners, and preserve strict session ownership checks when resuming sessions from chat-scoped API tokens.

Add focused regression coverage for direct base_url SSRF rejection, configured endpoint fallback behavior, token-owner scoping, URL validation, and null-owner session/endpoint handling.
2026-06-03 13:05:13 +01:00
Alexandre Teixeira 145f4fd2b4 feat(models): support pinned endpoint model IDs 2026-06-03 13:00:07 +01:00
Alexandre Teixeira 1284b14a13 feat(docker): add standalone GPU compose files for stack UIs 2026-06-03 12:54:35 +01:00
Alexandre Teixeira a75dd4a231 fix(search): apply recency UTC fix to live ranking module 2026-06-03 12:49:32 +01:00
Alexandre Teixeira 0deeba58ba tests(llm): cover Anthropic temperature clamping 2026-06-03 12:28:53 +01:00
pewdiepie-archdaemon 562bc4dedc Cookbook polish: auto-reconnect, ctx slider fixes, scoring, lots of UI
Backend (services/hwfit + routes):
- VRAM column sort now shows global highest first (was special-cased to
  ascending then truncated top-N, which made "highest VRAM" mathematically
  unreachable). Every column path uses reverse=True for the truncation.
- Hardware probe cache TTL 30min -> 24h so changing filters doesn't keep
  re-probing the rig during a session; Rescan button still forces fresh.
- Multi-GPU rigs filter GGUF Q*/IQ quants (vLLM/SGLang can't serve them);
  default non-prequantized to BF16 on 2+ GPUs.
- AWQ / AWQ-8bit / GPTQ-8bit get a -1.0 quality penalty so FP8 wins ties.
- Version-aware tiebreaker (parse Mn.n / Vn) — MiniMax-M2.7 ranks above M2.5.
- hf_models.json: zai-org/GLM-5.1 added; zai-org/GLM-5 quantization flipped
  Q4_K_M -> BF16. DeepSeek-V4-Flash / -Pro + their -Base variants registered
  with new FP4-MoE-Mixed / FP8-Mixed quant keys (calibrated BPP from the
  actual 156 GB / 284 GB disk footprints).
- New FP4-MoE-Mixed + FP8-Mixed entries in QUANT_BPP / QUANT_SPEED_MULT /
  QUANT_QUALITY_PENALTY / QUANT_BYTES_PER_PARAM / PREQUANTIZED_PREFIXES.

Frontend — Scan/Download:
- Engine + Quant swapped in the toolbar; Quant defaults to "All".
- Ctx (range slider) ported from origin/main: 8k/16k/32k/50k/128k/Max. Drag
  re-sorts by vram ascending (smallest fitting first); back to Max → score.
- Ctx slider rail now visible — was background:transparent in a duplicate
  later-cascade rule. Hardcoded grey + !important.
- Search input moved to the far right of the toolbar.
- Type/Standard default; "Context" not uppercased; Search placeholder dimmed.
- Engine "?" + Quant "?" inline help chips inside their dropdown boxes.
- Fit-column dot toggles fit-only filter; un-toggling re-sorts by VRAM desc.
- Quant column truncates to 9 chars + ellipsis ("FP4-MoE-M..."), full in
  tooltip. Smart title-suffix strips the parts already in the repo name
  (QuantTrio/MiniMax-M2-AWQ + quant AWQ-4bit -> just "(4bit)").
- Conditional warning for safetensors models on non-GPU rigs only.
- Dependency Install / Installed / Installed▾ / N/A all 75.85px wide.
- Rebuild llama.cpp moved into the llama_cpp dep row, styled as a tag.
- Foldable Download admin-card (h2 chevron); line under h2 only when folded.
- HF token save gets a green ✓ + "Saved" flash.
- Cached scan no longer counts stalled rows as downloaded.
- Footer: "Request it →" link with GitHub mark to the public discussion
  (#1962) for model-add requests.

Frontend — Running tab:
- Strict download-finish check (DOWNLOAD_OK or /snapshots/, not bare
  "Download complete"). True overall % for multi-shard downloads:
  ((N-1)+frac)/total instead of hf_transfer's per-shard aggregate.
- ETA in the uptime ticker: "downloading: 12m 34s · ETA 1h 23m".
- Clear button kills the tmux session too; if the output still shows a
  live shard line, the pill is hidden + relabels as "reconnect" + revives
  on click.
- Self-heal: on cookbook open AND every bg-monitor cycle (10s, throttled
  to 8s), scan persisted done/error/crashed downloads and probe their
  tmux session — if alive, flip status back to running and reattach.
- Per-launch zombie probe: clicking Download on a model whose persisted
  state is done but tmux is still alive revives the existing task and
  refuses to start a duplicate.
- Pre-launch GPU probe: vllm / sglang / diffusers serve check
  /api/cookbook/gpus first; warns + confirms if no GPU is visible.
- Server-side state guard: rejects "done" POSTs for downloads lacking
  DOWNLOAD_OK / DOWNLOAD_FAILED / /snapshots/ when the last-mentioned
  shard is N<total — stale tabs can't poison persisted state any more.
- Running count includes tasks whose output looks active even if persisted
  status got stuck. Dir text on the running row, font matched to uptime.

Serve panel:
- Ctx text input always resets to model max on open (default 20000 when
  metadata is missing).
- Max Seqs default 8 -> 4. KV Cache dtype select 32px tall.
- Lightning icon on Launch (same as Action toggle).
- Diagnosis card simplified (no fold/copy/dismiss), suggestion font
  matches body; action buttons get icons on the left (Retry/Copy/Edit/
  Install/Kill/Switch/etc.).
- Incomplete-download serve warning when model status is
  downloading / stalled / has_incomplete.
- MTP "?" tooltip ("supported on a few model families … up to ~3× faster").
2026-06-03 20:25:25 +09:00
pewdiepie-archdaemon 3706d756f3 Merge remote-tracking branch 'origin/main' into visual-pr-playground
# Conflicts:
#	routes/cookbook_routes.py
#	routes/hwfit_routes.py
#	services/hwfit/fit.py
#	services/hwfit/models.py
#	static/js/cookbook-diagnosis.js
#	static/js/cookbook-hwfit.js
#	static/js/cookbook.js
#	static/js/cookbookRunning.js
2026-06-03 16:49:10 +09:00
pewdiepie-archdaemon eb79b76432 Cookbook: scoring fixes, UI polish, false-finished + stale-state bug fixes
Backend (services/hwfit + routes):
- rank_models picks visible set by REQUESTED column, not always score —
  sorting by Param now shows highest-param models PERIOD (incl. too_tight).
- New fit_only param. Multi-GPU rigs filter GGUF Q*/IQ quants (vLLM/SGLang
  cannot serve them); default non-prequantized to BF16 on 2+ GPUs.
- AWQ / GPTQ-8bit get a -1.0 quality penalty (was 0.0, tied with FP8), so
  FP8 wins when both fit.
- Version-aware tiebreaker (parse Mn.n / Vn) — MiniMax-M2.7 ranks above
  M2.5 on equal composite score; >=100B integers not misread as versions.
- /api/cookbook/hf-latest no longer drops models without an "NB" pattern in
  the repo id (MiniMax-M2.7, DeepSeek-V4-Pro etc. were silently filtered).
- Cached-model scan: atexit flushes models JSON even if the script is
  killed mid-walk; each scan_dir wrapped in try/except; timeout 60s -> 180s.
- KB granularity for sub-MB sizes (was "0 MB" for 12 KB shells). New
  "stalled" status for shells <1 MB with no .incomplete files.
- /api/cookbook/state POST guard: rejects "done" download tasks lacking
  DOWNLOAD_OK / DOWNLOAD_FAILED / /snapshots/ when the last-mentioned
  shard is N<total — stops stale tabs from poisoning persisted state.
- hf_models.json: add zai-org/GLM-5.1; flip zai-org/GLM-5 quantization
  Q4_K_M -> BF16 (it is the native base, not a quant).

Frontend (static/js):
- Scan/Download toolbar: quant defaults to All; ctx slider (8k/16k/32k/
  50k/128k/Max) ported from origin/main with sort=fit on drag, sort=score
  on Max. GPU toggle commits _activeCount to maxGpu on initial render. Fit
  column header tagged with active budget (RAM / GPU / N GPU).
- Foldable Download admin-card: the Download h2 is the chevron trigger;
  state persists in localStorage.
- Download card surfaces destination dir (Dir: <path>). Same dir on running
  task row, font/color matched to uptime (9px Fira Code muted, opacity .4).
- Serve panel ctx text input always resets to model max on open. Sub-MB
  cached models show with red "download stalled" badge.
- Bulk-select Cancel + Delete reset the Select button label on exit.
- Cookbook running: false-finished bug fixed — DOWNLOAD_OK or /snapshots/
  required; bare "Download complete" no longer marks the task done after
  the first config file. Clear button now sends tmux kill-session too.
  True overall % for multi-shard downloads: ((N-1)+frac)/total instead of
  hf_transfer per-shard aggregate.
- Diagnosis card simplified: removed fold toggle, copy button, dismiss X.
  Suggestion font matches message body (12px).
- HF token field flashes green check + "Saved" on save.
- Cached scan no longer counts stalled rows as downloaded in Scan/Download.

CSS:
- dep Install button width pinned to 76px to match Installed split.
- task-sub row +1px; task-status badge gets margin-right 8px.
- Ctx slider styled like gallery editor sliders (thin pill rail, red thumb).
- Bulk-select cancel button top -3px -> -5px.
2026-06-03 16:32:20 +09:00
38 changed files with 3006 additions and 457 deletions
+2
View File
@@ -31,6 +31,7 @@ body:
attributes: attributes:
label: Install Method label: Install Method
options: options:
- "-- Please Select --"
- Docker (docker compose up) - Docker (docker compose up)
- Manual Python install (pip / venv) - Manual Python install (pip / venv)
- Windows native (launch-windows.ps1) - Windows native (launch-windows.ps1)
@@ -44,6 +45,7 @@ body:
attributes: attributes:
label: Operating System label: Operating System
options: options:
- "-- Please Select --"
- Linux - Linux
- macOS - macOS
- Windows - Windows
@@ -32,6 +32,7 @@ body:
label: Area label: Area
description: Which part of the application does this affect? description: Which part of the application does this affect?
options: options:
- "-- Please Select --"
- Chat / Agent - Chat / Agent
- Email - Email
- Calendar - Calendar
@@ -83,6 +84,7 @@ body:
attributes: attributes:
label: Are you willing to implement this? label: Are you willing to implement this?
options: options:
- "-- Please Select --"
- "Yes — I can open a PR" - "Yes — I can open a PR"
- "Partially — I can help but need guidance" - "Partially — I can help but need guidance"
- "No — I am only filing the request" - "No — I am only filing the request"
+165
View File
@@ -0,0 +1,165 @@
// @ts-check
'use strict';
/** @param {{ github: import('@octokit/rest').Octokit, context: import('@actions/github').context, core: import('@actions/core') }} */
module.exports = async ({ github, context, core }) => {
const issue = context.payload.issue;
const body = (issue.body || '').trim();
const labels = issue.labels.map(l => l.name);
const owner = context.repo.owner;
const repo = context.repo.repo;
const isBug = labels.includes('bug');
const isFeature = labels.includes('enhancement');
// Extract a Section's text, stripping HTML comments. Matches any heading
// depth (#, ##, ###, …) so a manually-written body isn't penalised for
// using a different number of hashes than the issue form generates.
function section(heading) {
const re = new RegExp(`#+\\s+${heading}\\s*([\\s\\S]*?)(?=\\n#+\\s+|$)`, 'i');
const m = body.match(re);
return m ? m[1].replace(/<!--[\s\S]*?-->/g, '').trim() : '';
}
const failures = [];
// ── Common: body must exist ───────────────────────────────────────────────
if (body.length < 50) {
failures.push(
'**Description** — body is empty or too short. ' +
'Please open the issue using one of the provided templates.',
);
}
// An issue is one or the other — never both. Resolve to a single type so the
// validation can't run two conflicting blocks at once.
const type = isBug && isFeature ? 'conflict' : isBug ? 'bug' : isFeature ? 'feature' : 'untyped';
switch (type) {
case 'conflict':
failures.push('**Labels** — an issue cannot be both `bug` and `enhancement`. Remove one label.');
break;
case 'bug': {
if (!section('Install Method')) {
failures.push('**Install Method** — select how you installed Odysseus');
}
if (!section('Operating System')) {
failures.push('**Operating System** — select your OS');
}
const stepsText = section('Steps to Reproduce');
if (!stepsText || !/\d+\.|[-*]/.test(stepsText)) {
failures.push('**Steps to Reproduce** — must include at least one numbered or bulleted step');
}
if (section('Expected Behaviour').length < 10) {
failures.push('**Expected Behaviour** — section is empty or too short');
}
if (section('Actual Behaviour').length < 10) {
failures.push('**Actual Behaviour** — section is empty or too short');
}
break;
}
case 'feature':
if (!section('Area')) {
failures.push('**Area** — select which part of the application this affects');
}
if (section('Problem or Motivation').length < 20) {
failures.push(
'**Problem or Motivation** — section is empty or too short ' +
'(explain the concrete problem this solves)',
);
}
if (section('Proposed Solution').length < 20) {
failures.push(
'**Proposed Solution** — section is empty or too short ' +
'(describe the change you want to see)',
);
}
if (!section('Are you willing to implement this\\?')) {
failures.push('**Are you willing to implement this?** — select an option');
}
break;
// 'untyped' → only the common body-length check applies.
}
// ── Labels ────────────────────────────────────────────────────────────────
// These labels are expected to already exist in the repo — managing the
// repo's label set is the maintainer's job, not this workflow's. We check a
// label exists before applying it (issues.addLabels would otherwise silently
// create a missing label) and fail soft — warn and skip — if it's absent.
async function labelExists(name) {
try {
await github.rest.issues.getLabel({ owner, repo, name });
return true;
} catch (e) {
if (e.status === 404) return false;
throw e;
}
}
async function addLabel(name) {
if (await labelExists(name)) {
await github.rest.issues.addLabels({ owner, repo, issue_number: issue.number, labels: [name] });
} else {
core.warning(`Label "${name}" does not exist in the repo — skipping. Create it once to enable labelling.`);
}
}
async function dropLabel(name) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: issue.number, name });
} catch (e) {
if (e.status !== 404 && e.status !== 410) throw e;
}
}
// ── Find existing bot comment to update in-place ──────────────────────────
const MARKER = '<!-- issue-description-check -->';
const { data: comments } = await github.rest.issues.listComments({
owner, repo, issue_number: issue.number,
});
const existing = comments.find(c => c.user.type === 'Bot' && c.body.includes(MARKER));
const LABEL_BAD = 'needs more info';
const LABEL_GOOD = 'ready for review';
if (failures.length === 0) {
if (existing) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
}
await dropLabel(LABEL_BAD);
await addLabel(LABEL_GOOD);
} else {
const list = failures.map(f => `- ${f}`).join('\n');
const commentBody = [
MARKER,
'⚠️ **Issue description is incomplete.** Please update the following sections:',
'',
list,
'',
'_This comment is deleted automatically once all sections are complete._',
].join('\n');
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: commentBody });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: commentBody });
}
await dropLabel(LABEL_GOOD);
await addLabel(LABEL_BAD);
core.setFailed(`Issue description has ${failures.length} issue(s) — see bot comment for details.`);
}
};
+121
View File
@@ -0,0 +1,121 @@
// @ts-check
'use strict';
/** @param {{ github: import('@octokit/rest').Octokit, context: import('@actions/github').context, core: import('@actions/core') }} */
module.exports = async ({ github, context, core }) => {
const body = context.payload.pull_request.body || '';
const prNum = context.payload.pull_request.number;
const MARKER = '<!-- pr-description-check-bot -->';
const owner = context.repo.owner;
const repo = context.repo.repo;
// Strip HTML comments so placeholder text does not count as content.
function strip(text) {
return (text ?? '').replace(/<!--[\s\S]*?-->/g, '').trim();
}
// Extract the text content of a Section. Matches any heading depth (#, ##,
// ###, …) so the check doesn't break if the template's heading level changes.
function section(heading) {
const m = body.match(new RegExp(`#+\\s+${heading}[\\s\\S]*?(?=\\n#+\\s+|$)`, 'i'));
return strip(m?.[0].replace(new RegExp(`#+\\s+${heading}`, 'i'), '') ?? '');
}
const problems = [];
// 1. Summary must be filled in.
if (section('Summary').length < 20) {
problems.push('**Summary** is empty or too short — describe what changed and why.');
}
// 2. Linked Issue must reference a real issue. Accept a bare #NNN, a closing
// keyword + #NNN, or a full issue URL (e.g. .../issues/123) — the strict
// keyword-prefixed form previously false-flagged correctly-linked PRs.
const linkedSection = section('Linked Issue');
const hasIssueRef = /#\d+/.test(linkedSection) || /\/issues\/\d+/.test(linkedSection);
if (!linkedSection || !hasIssueRef) {
problems.push('**Linked Issue** — add a reference like `Fixes #NNN`, a bare `#NNN`, or a link to the issue.');
}
// 3. At least one Type of Change box must be checked.
const typeBlock = body.match(/##\s+Type of Change[\s\S]*?(?=\n##\s|$)/i)?.[0] ?? '';
if (!/- \[x\]/i.test(typeBlock)) {
problems.push('**Type of Change** — check at least one box.');
}
// 4. Duplicate-search checklist item must be checked.
if (!/- \[x\] I searched/i.test(body)) {
problems.push('**Checklist** — check the duplicate-search box to confirm you searched existing issues and PRs.');
}
// 5. How to Test must have at least one numbered step.
const howTo = section('How to Test');
if (!howTo || !/\d+\.\s*\S/.test(howTo)) {
problems.push('**How to Test** — add at least one numbered step a reviewer can follow to verify this works.');
}
// ── Comment ──────────────────────────────────────────────────────────────
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: prNum, per_page: 100,
});
const existing = comments.find(c => (c.body ?? '').includes(MARKER));
if (problems.length === 0) {
if (existing) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
}
} else {
const commentBody = [
MARKER,
'⚠️ **PR description — action needed**',
'',
'The following required sections are missing or incomplete. Please update the PR description to address them:',
'',
problems.map(p => `- ${p}`).join('\n'),
'',
'---',
'_This comment is deleted automatically once all sections are complete._',
].join('\n');
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: commentBody });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: prNum, body: commentBody });
}
}
// ── Labels ────────────────────────────────────────────────────────────────
// These labels are expected to already exist in the repo — managing the
// repo's label set is the maintainer's job, not this workflow's. We check a
// label exists before applying it (issues.addLabels would otherwise silently
// create a missing label) and fail soft — warn and skip — if it's absent.
async function labelExists(name) {
try {
await github.rest.issues.getLabel({ owner, repo, name });
return true;
} catch (e) {
if (e.status === 404) return false;
throw e;
}
}
async function swapLabel(num, add, remove) {
if (await labelExists(add)) {
await github.rest.issues.addLabels({ owner, repo, issue_number: num, labels: [add] });
} else {
core.warning(`Label "${add}" does not exist in the repo — skipping. Create it once to enable labelling.`);
}
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: num, name: remove });
} catch (e) {
if (e.status !== 404 && e.status !== 410) throw e;
}
}
if (problems.length === 0) {
await swapLabel(prNum, 'ready for review', 'needs work');
} else {
await swapLabel(prNum, 'needs work', 'ready for review');
core.setFailed(`PR description has ${problems.length} issue(s) — see bot comment for details.`);
}
};
@@ -0,0 +1,23 @@
name: ci / issue description check
on:
issues:
types: [opened, edited, reopened]
permissions:
issues: write
jobs:
check:
name: Check issue description
runs-on: ubuntu-latest
# Skip bots (Dependabot, release-drafter, etc.)
if: ${{ github.event.issue.user.type != 'Bot' }}
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
- uses: actions/github-script@v7
with:
script: return require('./.github/scripts/check-issue-description.js')({github, context, core})
@@ -0,0 +1,28 @@
name: ci / PR description check
on:
pull_request_target:
types: [opened, edited, synchronize, reopened]
# pull_request_target runs in the base-repo context (has secrets).
# The checkout below pins to the base branch so no fork code is executed.
# The script only reads context.payload and calls the GitHub API.
permissions:
issues: write
pull-requests: write
jobs:
check-description:
name: Check PR description
runs-on: ubuntu-latest
# Skip bots — they open PRs programmatically and have their own process.
if: github.event.pull_request.user.type != 'Bot'
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.base_ref }}
sparse-checkout: .github/scripts
- uses: actions/github-script@v7
with:
script: return require('./.github/scripts/check-pr-description.js')({github, context, core})
+14
View File
@@ -189,6 +189,20 @@ RENDER_GID=989
For NVIDIA/AMD GPU support, also read the comments in the selected overlay file: docker/gpu.nvidia.yml or docker/gpu.amd.yml. For NVIDIA/AMD GPU support, also read the comments in the selected overlay file: docker/gpu.nvidia.yml or docker/gpu.amd.yml.
**Stack-management UIs (Portainer, Coolify, Dockhand, etc.).** These tools
often accept only a single Compose file and do not reliably honor `COMPOSE_FILE`
or multiple `-f` overlays. CLI users should keep using the `COMPOSE_FILE`
overlay workflow above. For stack UIs, point the stack at one of the standalone
files instead, which bundle the base stack plus the GPU settings:
- `docker-compose.gpu-nvidia.yml` — still requires the NVIDIA Container Toolkit
on the host.
- `docker-compose.gpu-amd.yml` — still requires host ROCm/kfd/DRI setup, the
`video`/`render` group membership, and `RENDER_GID` when needed.
The base `docker-compose.yml` plus the `docker/gpu.*.yml` overlays remain the
source of truth; the standalone files mirror them for single-file deployments.
Verify after enabling either overlay: Verify after enabling either overlay:
```bash ```bash
+20
View File
@@ -334,6 +334,7 @@ class ModelEndpoint(TimestampMixin, Base):
is_enabled = Column(Boolean, default=True) is_enabled = Column(Boolean, default=True)
hidden_models = Column(Text, nullable=True) # JSON list of model IDs that failed probing hidden_models = Column(Text, nullable=True) # JSON list of model IDs that failed probing
cached_models = Column(Text, nullable=True) # JSON list of last-known model IDs (avoids probe on list) cached_models = Column(Text, nullable=True) # JSON list of last-known model IDs (avoids probe on list)
pinned_models = Column(Text, nullable=True) # JSON list of admin-pinned model IDs (manual, may not appear in /v1/models)
model_type = Column(String, nullable=True, default="llm") # "llm" or "image" model_type = Column(String, nullable=True, default="llm") # "llm" or "image"
# Whether models on this endpoint accept OpenAI-style function # Whether models on this endpoint accept OpenAI-style function
# schemas + emit `tool_calls`. Auto-detected at Cookbook auto- # schemas + emit `tool_calls`. Auto-detected at Cookbook auto-
@@ -856,6 +857,24 @@ def _migrate_add_cached_models_column():
except Exception as e: except Exception as e:
logging.getLogger(__name__).warning(f"cached_models migration failed: {e}") logging.getLogger(__name__).warning(f"cached_models migration failed: {e}")
def _migrate_add_pinned_models_column():
"""Add pinned_models column to model_endpoints if it doesn't exist."""
import sqlite3
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
try:
conn = sqlite3.connect(db_path)
cursor = conn.execute("PRAGMA table_info(model_endpoints)")
columns = [row[1] for row in cursor.fetchall()]
if columns and "pinned_models" not in columns:
conn.execute("ALTER TABLE model_endpoints ADD COLUMN pinned_models TEXT")
conn.commit()
logging.getLogger(__name__).info("Migrated: added 'pinned_models' column to model_endpoints")
conn.close()
except Exception as e:
logging.getLogger(__name__).warning(f"pinned_models migration failed: {e}")
def _migrate_add_notes_sort_order(): def _migrate_add_notes_sort_order():
"""Add sort_order, image_url, repeat columns to notes if they don't exist.""" """Add sort_order, image_url, repeat columns to notes if they don't exist."""
import sqlite3 import sqlite3
@@ -1511,6 +1530,7 @@ def init_db():
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_migrate_add_hidden_models_column() _migrate_add_hidden_models_column()
_migrate_add_cached_models_column() _migrate_add_cached_models_column()
_migrate_add_pinned_models_column()
_migrate_add_notes_sort_order() _migrate_add_notes_sort_order()
_migrate_add_model_type_column() _migrate_add_model_type_column()
_migrate_add_model_endpoint_owner_column() _migrate_add_model_endpoint_owner_column()
+164
View File
@@ -0,0 +1,164 @@
# Standalone AMD ROCm GPU Compose file for stack-management UIs (Portainer,
# Coolify, Dockhand, etc.) that accept only a single Compose file and do not
# reliably honor COMPOSE_FILE or multiple `-f` overlays.
#
# This is equivalent to: docker-compose.yml + docker/gpu.amd.yml.
# The base docker-compose.yml plus the docker/gpu.amd.yml overlay remain the
# source of truth — CLI users should keep using the COMPOSE_FILE overlay
# workflow. Keep this file in sync with both when either changes.
#
# Requires ROCm drivers on the host (kfd + DRI devices) and the host user
# running Docker in the `video` and `render` groups. Set RENDER_GID to your
# host's numeric render group id when needed. See docker/gpu.amd.yml for details.
services:
odysseus:
build: .
ports:
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
volumes:
- ./data:/app/data:z
- ./logs:/app/logs:z
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
# add the shown public key to each remote server's authorized_keys.
- ./data/ssh:/app/.ssh:z
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
# container, so persist its HuggingFace cache under ./data/huggingface.
- ./data/huggingface:/app/.cache/huggingface:z
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
# land under /app/.local for the odysseus user. Persist them so a
# container recreate does not silently remove installed serve engines.
- ./data/local:/app/.local:z
extra_hosts:
# Lets the container reach local services on the Docker host, including
# Ollama at http://host.docker.internal:11434.
- "host.docker.internal:host-gateway"
environment:
- LLM_HOST=${LLM_HOST:-localhost}
- LLM_HOSTS=${LLM_HOSTS:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-}
- RESEARCH_LLM_ENDPOINT=${RESEARCH_LLM_ENDPOINT:-}
- HF_TOKEN=${HF_TOKEN:-}
- HUGGING_FACE_HUB_TOKEN=${HUGGING_FACE_HUB_TOKEN:-}
- SEARXNG_INSTANCE=http://searxng:8080
- CHROMADB_HOST=chromadb
- CHROMADB_PORT=8000
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
- AUTH_ENABLED=${AUTH_ENABLED:-true}
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
- SECURE_COOKIES=${SECURE_COOKIES:-false}
- EMBEDDING_URL=${EMBEDDING_URL:-}
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
- FASTEMBED_MODEL=${FASTEMBED_MODEL:-sentence-transformers/all-MiniLM-L6-v2}
- FASTEMBED_CACHE_PATH=${FASTEMBED_CACHE_PATH:-}
- CLEANUP_INTERVAL_HOURS=${CLEANUP_INTERVAL_HOURS:-24}
- ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1}
- ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1}
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
- SERPER_API_KEY=${SERPER_API_KEY:-}
# PUID / PGID — the user/group the container drops to before
# running uvicorn (entrypoint also chowns /app/data + /app/logs
# to match, so bind-mounted files stay editable from the host).
# 1000 is the default first user on most Linux installs. If your
# host user has a different id, override here or via .env, e.g.:
# PUID=1001
# PGID=1001
# Find yours with: id -u / id -g
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
depends_on:
searxng:
condition: service_healthy
chromadb:
condition: service_started
restart: unless-stopped
# AMD ROCm overlay (from docker/gpu.amd.yml).
devices:
- /dev/kfd
- /dev/dri
group_add:
- video
- ${RENDER_GID:-render}
chromadb:
image: docker.io/chromadb/chroma:latest
ports:
- "${CHROMADB_BIND:-127.0.0.1}:8100:8000"
volumes:
- chromadb-data:/chroma/chroma
environment:
- ANONYMIZED_TELEMETRY=FALSE
restart: unless-stopped
searxng:
# Pinned, not :latest — odysseus waits on searxng's healthcheck
# (depends_on: condition: service_healthy), so a broken upstream `latest`
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
# Bump this deliberately after verifying a newer tag boots clean.
image: docker.io/searxng/searxng:2026.5.31-7159b8aed
entrypoint:
- /bin/sh
- -c
- |
set -eu
if [ ! -s /etc/searxng/settings.yml ] || grep -q 'odysseus-local-searxng-json-2026-05-30\|__SEARXNG_SECRET__' /etc/searxng/settings.yml; then
secret="$${SEARXNG_SECRET:-}"
if [ -z "$$secret" ]; then
secret="$$(python -c 'import secrets; print(secrets.token_urlsafe(48))')"
fi
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
fi
exec /usr/local/searxng/entrypoint.sh
ports:
- "127.0.0.1:8080:8080"
volumes:
- searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
environment:
- SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
# The official searxng image runs as the non-root `searxng` user, but its
# entrypoint still needs to chown /etc/searxng on first boot, drop privs via
# su-exec, and (with our wrapper above) write settings.yml into the named
# volume. Without these capabilities the wrapper aborts at the redirection
# with EACCES and the container fails its healthcheck with permission
# errors during setup. Mirrors the cap set recommended by the upstream
# searxng-docker compose file. See issue #721.
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
- DAC_OVERRIDE
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8080/', timeout=5).read(1)\""]
interval: 5s
timeout: 6s
retries: 20
start_period: 10s
restart: unless-stopped
ntfy:
image: docker.io/binwiederhier/ntfy
command: serve
ports:
- "${NTFY_BIND:-127.0.0.1}:8091:80"
volumes:
- ntfy-cache:/var/cache/ntfy
environment:
- NTFY_BASE_URL=${NTFY_BASE_URL:-http://localhost:8091}
restart: unless-stopped
volumes:
searxng-data:
chromadb-data:
ntfy-cache:
+167
View File
@@ -0,0 +1,167 @@
# Standalone NVIDIA GPU Compose file for stack-management UIs (Portainer,
# Coolify, Dockhand, etc.) that accept only a single Compose file and do not
# reliably honor COMPOSE_FILE or multiple `-f` overlays.
#
# This is equivalent to: docker-compose.yml + docker/gpu.nvidia.yml.
# The base docker-compose.yml plus the docker/gpu.nvidia.yml overlay remain
# the source of truth — CLI users should keep using the COMPOSE_FILE overlay
# workflow. Keep this file in sync with both when either changes.
#
# Requires the NVIDIA Container Toolkit on the host. See docker/gpu.nvidia.yml
# for setup details.
services:
odysseus:
build: .
ports:
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
volumes:
- ./data:/app/data:z
- ./logs:/app/logs:z
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
# add the shown public key to each remote server's authorized_keys.
- ./data/ssh:/app/.ssh:z
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
# container, so persist its HuggingFace cache under ./data/huggingface.
- ./data/huggingface:/app/.cache/huggingface:z
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
# land under /app/.local for the odysseus user. Persist them so a
# container recreate does not silently remove installed serve engines.
- ./data/local:/app/.local:z
extra_hosts:
# Lets the container reach local services on the Docker host, including
# Ollama at http://host.docker.internal:11434.
- "host.docker.internal:host-gateway"
environment:
- LLM_HOST=${LLM_HOST:-localhost}
- LLM_HOSTS=${LLM_HOSTS:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-}
- RESEARCH_LLM_ENDPOINT=${RESEARCH_LLM_ENDPOINT:-}
- HF_TOKEN=${HF_TOKEN:-}
- HUGGING_FACE_HUB_TOKEN=${HUGGING_FACE_HUB_TOKEN:-}
- SEARXNG_INSTANCE=http://searxng:8080
- CHROMADB_HOST=chromadb
- CHROMADB_PORT=8000
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
- AUTH_ENABLED=${AUTH_ENABLED:-true}
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
- SECURE_COOKIES=${SECURE_COOKIES:-false}
- EMBEDDING_URL=${EMBEDDING_URL:-}
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
- FASTEMBED_MODEL=${FASTEMBED_MODEL:-sentence-transformers/all-MiniLM-L6-v2}
- FASTEMBED_CACHE_PATH=${FASTEMBED_CACHE_PATH:-}
- CLEANUP_INTERVAL_HOURS=${CLEANUP_INTERVAL_HOURS:-24}
- ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1}
- ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1}
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
- SERPER_API_KEY=${SERPER_API_KEY:-}
# PUID / PGID — the user/group the container drops to before
# running uvicorn (entrypoint also chowns /app/data + /app/logs
# to match, so bind-mounted files stay editable from the host).
# 1000 is the default first user on most Linux installs. If your
# host user has a different id, override here or via .env, e.g.:
# PUID=1001
# PGID=1001
# Find yours with: id -u / id -g
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
# NVIDIA overlay (from docker/gpu.nvidia.yml).
- NVIDIA_VISIBLE_DEVICES=all
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
depends_on:
searxng:
condition: service_healthy
chromadb:
condition: service_started
restart: unless-stopped
# NVIDIA overlay (from docker/gpu.nvidia.yml).
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
chromadb:
image: docker.io/chromadb/chroma:latest
ports:
- "${CHROMADB_BIND:-127.0.0.1}:8100:8000"
volumes:
- chromadb-data:/chroma/chroma
environment:
- ANONYMIZED_TELEMETRY=FALSE
restart: unless-stopped
searxng:
# Pinned, not :latest — odysseus waits on searxng's healthcheck
# (depends_on: condition: service_healthy), so a broken upstream `latest`
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
# Bump this deliberately after verifying a newer tag boots clean.
image: docker.io/searxng/searxng:2026.5.31-7159b8aed
entrypoint:
- /bin/sh
- -c
- |
set -eu
if [ ! -s /etc/searxng/settings.yml ] || grep -q 'odysseus-local-searxng-json-2026-05-30\|__SEARXNG_SECRET__' /etc/searxng/settings.yml; then
secret="$${SEARXNG_SECRET:-}"
if [ -z "$$secret" ]; then
secret="$$(python -c 'import secrets; print(secrets.token_urlsafe(48))')"
fi
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
fi
exec /usr/local/searxng/entrypoint.sh
ports:
- "127.0.0.1:8080:8080"
volumes:
- searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
environment:
- SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
# The official searxng image runs as the non-root `searxng` user, but its
# entrypoint still needs to chown /etc/searxng on first boot, drop privs via
# su-exec, and (with our wrapper above) write settings.yml into the named
# volume. Without these capabilities the wrapper aborts at the redirection
# with EACCES and the container fails its healthcheck with permission
# errors during setup. Mirrors the cap set recommended by the upstream
# searxng-docker compose file. See issue #721.
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
- DAC_OVERRIDE
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8080/', timeout=5).read(1)\""]
interval: 5s
timeout: 6s
retries: 20
start_period: 10s
restart: unless-stopped
ntfy:
image: docker.io/binwiederhier/ntfy
command: serve
ports:
- "${NTFY_BIND:-127.0.0.1}:8091:80"
volumes:
- ntfy-cache:/var/cache/ntfy
environment:
- NTFY_BASE_URL=${NTFY_BASE_URL:-http://localhost:8091}
restart: unless-stopped
volumes:
searxng-data:
chromadb-data:
ntfy-cache:
+35 -4
View File
@@ -1769,6 +1769,33 @@ def setup_cookbook_routes() -> APIRouter:
disk_tasks = on_disk.get("tasks") or [] if isinstance(on_disk, dict) else [] disk_tasks = on_disk.get("tasks") or [] if isinstance(on_disk, dict) else []
incoming_tasks = data.get("tasks") if isinstance(data.get("tasks"), list) else [] incoming_tasks = data.get("tasks") if isinstance(data.get("tasks"), list) else []
# Anti-poisoning guard: a stale browser tab can keep POSTing a
# download task as status='done' from before the strict-finish
# fix landed, undoing any server-side correction. For each
# incoming "done" download, override to "running" if the last
# shard pattern says N<total AND no DOWNLOAD_OK/DOWNLOAD_FAILED/
# /snapshots/ sentinel is in the output.
import re as _re_dl
for _it in incoming_tasks:
if (not isinstance(_it, dict)) or _it.get("type") != "download" or _it.get("status") != "done":
continue
_out = _it.get("output") or ""
if ("DOWNLOAD_OK" in _out) or ("DOWNLOAD_FAILED" in _out) or ("/snapshots/" in _out):
continue
_shards = _re_dl.findall(r"model-(\d+)-of-(\d+)\.safetensors", _out)
if _shards:
_n, _tot = _shards[-1]
if int(_n) < int(_tot):
logger.info(f"cookbook state POST: rejecting stale done for {_it.get('sessionId')} "
f"(last shard {_n}/{_tot}, no DOWNLOAD_OK)")
_it["status"] = "running"
else:
_completed = _out.count("Download complete")
_starts = _out.count("Downloading '")
if _starts > _completed:
logger.info(f"cookbook state POST: rejecting stale done for {_it.get('sessionId')} "
f"({_completed}/{_starts} files complete, no DOWNLOAD_OK)")
_it["status"] = "running"
incoming_ids = {t.get("sessionId") for t in incoming_tasks if isinstance(t, dict) and t.get("sessionId")} incoming_ids = {t.get("sessionId") for t in incoming_tasks if isinstance(t, dict) and t.get("sessionId")}
import time as _t import time as _t
now_ms = int(_t.time() * 1000) now_ms = int(_t.time() * 1000)
@@ -1887,10 +1914,14 @@ def setup_cookbook_routes() -> APIRouter:
if vram_gb > 0 and needed_vram is not None and needed_vram > vram_gb: if vram_gb > 0 and needed_vram is not None and needed_vram > vram_gb:
continue continue
# Skip if no size info — without a size we can't tell if it's a real # Unknown-size models (e.g. MiniMax-M2.7, DeepSeek-V4-Flash) have no
# full-weight model or a tiny adapter, so we'd rather drop it # "NB" in the repo id, so the regex above can't extract their
if est_vram is None: # param count. Previously we dropped them entirely, which made
continue # brand-new flagship releases silently vanish from this list even
# on rigs with hundreds of GB of VRAM. Adapters/LoRAs are already
# filtered by _is_excluded(), so what falls through here is
# overwhelmingly full models — keep them, just without a size
# badge (the frontend handles needed_vram_gb=null gracefully).
out.append({ out.append({
"repo_id": repo_id, "repo_id": repo_id,
+2 -2
View File
@@ -108,7 +108,7 @@ def setup_hwfit_routes():
return detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh) return detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh)
@router.get("/models") @router.get("/models")
def get_models(use_case: str = "", sort: str = "score", limit: int = 50, search: str = "", host: str = "", quant: str = "", ctx: str = "", gpu_count: str = "", gpu_group: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False): def get_models(use_case: str = "", sort: str = "score", limit: int = 50, search: str = "", host: str = "", quant: str = "", ctx: str = "", gpu_count: str = "", gpu_group: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False, fit_only: bool = False):
"""Rank LLM models against detected hardware and return scored results. """Rank LLM models against detected hardware and return scored results.
gpu_count: override GPU count (0 = CPU only, 1-N = simulate N GPUs of the gpu_count: override GPU count (0 = CPU only, 1-N = simulate N GPUs of the
active group). gpu_group: index into system.gpu_groups (the homogeneous active group). gpu_group: index into system.gpu_groups (the homogeneous
@@ -196,7 +196,7 @@ def setup_hwfit_routes():
if target_context is not None: if target_context is not None:
target_context = max(1024, min(target_context, 1000000)) target_context = max(1024, min(target_context, 1000000))
results = rank_models(system, use_case=use_case or None, limit=limit, search=search or None, sort=sort, quant=quant or None, target_context=target_context) results = rank_models(system, use_case=use_case or None, limit=limit, search=search or None, sort=sort, quant=quant or None, target_context=target_context, fit_only=fit_only)
return {"system": system, "models": results} return {"system": system, "models": results}
@router.get("/profiles") @router.get("/profiles")
+129 -25
View File
@@ -633,13 +633,68 @@ def _model_endpoint_error_message(base_url: str, ping: Dict[str, Any] = None) ->
return "No models found for that provider/key." return "No models found for that provider/key."
def _visible_models(cached_models, hidden_models): def _normalize_model_ids(value):
"""Filter cached model IDs by hidden_models. Returns list of visible IDs.""" """Coerce a model-ID input into a clean, ordered list of strings.
all_models = json.loads(cached_models) if isinstance(cached_models, str) else (cached_models or [])
Accepts a list, a JSON-encoded list string, or a comma/newline separated
string (handy for form or backend API input). Trims whitespace, drops
empty and non-string values, and de-duplicates preserving first-seen order.
"""
if value is None:
return []
items = value
if isinstance(value, str):
text = value.strip()
if not text:
return []
try:
parsed = json.loads(text)
except Exception:
parsed = None
items = parsed if isinstance(parsed, list) else re.split(r"[,\n]", text)
if not isinstance(items, list):
return []
out, seen = [], set()
for item in items:
if not isinstance(item, str):
continue
s = item.strip()
if not s or s in seen:
continue
seen.add(s)
out.append(s)
return out
def _merge_model_ids(*lists):
"""Concatenate model-ID lists, de-duplicating and preserving order."""
out, seen = [], set()
for ids in lists:
for m in (ids or []):
if not isinstance(m, str) or m in seen:
continue
seen.add(m)
out.append(m)
return out
def _visible_models(cached_models, hidden_models, pinned_models=None):
"""Merge cached + pinned model IDs, then filter out hidden ones.
Pinned IDs are admin-entered and may not appear in cached_models (e.g.
cloud deployment IDs the provider does not list in /v1/models). Returns an
ordered, de-duplicated list of visible IDs.
"""
# Normalize each input so JSON strings, lists, comma/newline strings, and
# malformed strings are all handled without raising.
merged = _merge_model_ids(
_normalize_model_ids(cached_models),
_normalize_model_ids(pinned_models),
)
if not hidden_models: if not hidden_models:
return all_models return merged
hidden = set(json.loads(hidden_models) if isinstance(hidden_models, str) else (hidden_models or [])) hidden = set(_normalize_model_ids(hidden_models))
return [m for m in all_models if m not in hidden] return [m for m in merged if m not in hidden]
def setup_model_routes(model_discovery): def setup_model_routes(model_discovery):
@@ -1123,10 +1178,13 @@ def setup_model_routes(model_discovery):
hidden = set(json.loads(r.hidden_models)) hidden = set(json.loads(r.hidden_models))
except Exception: except Exception:
pass pass
visible = [m for m in all_models if m not in hidden] pinned = _normalize_model_ids(getattr(r, "pinned_models", None))
status = "online" if all_models else "offline" visible = _visible_models(all_models, r.hidden_models, pinned)
# Endpoint counts as reachable if it has any model — including
# admin-pinned IDs that a probe would never surface.
status = "online" if (all_models or pinned) else "offline"
ping = None ping = None
if not all_models and r.is_enabled: if not all_models and not pinned and r.is_enabled:
ping = _ping_endpoint(r.base_url, r.api_key, timeout=1.0) ping = _ping_endpoint(r.base_url, r.api_key, timeout=1.0)
if ping.get("reachable"): if ping.get("reachable"):
status = "empty" status = "empty"
@@ -1137,6 +1195,7 @@ def setup_model_routes(model_discovery):
"has_key": bool(r.api_key), "has_key": bool(r.api_key),
"is_enabled": r.is_enabled, "is_enabled": r.is_enabled,
"models": visible, "models": visible,
"pinned_models": pinned,
"hidden_count": len(hidden), "hidden_count": len(hidden),
"online": status != "offline", "online": status != "offline",
"status": status, "status": status,
@@ -1158,6 +1217,7 @@ def setup_model_routes(model_discovery):
require_models: str = Form("false"), require_models: str = Form("false"),
model_type: str = Form("llm"), model_type: str = Form("llm"),
supports_tools: str = Form(""), # "true"/"false"/"" (unknown) supports_tools: str = Form(""), # "true"/"false"/"" (unknown)
pinned_models: str = Form(""), # admin-pinned IDs: list/JSON/comma/newline
container_local: str = Form("false"), container_local: str = Form("false"),
# Default `shared=true` → endpoints are visible to all users (the # Default `shared=true` → endpoints are visible to all users (the
# app's historical behaviour). Admins can pass `shared=false` to # app's historical behaviour). Admins can pass `shared=false` to
@@ -1199,11 +1259,28 @@ def setup_model_routes(model_discovery):
.first() .first()
) )
if existing: if existing:
# Persist any incoming pinned IDs onto the existing row. An
# empty/omitted form field must not wipe previously pinned IDs.
_incoming_pinned = _normalize_model_ids(pinned_models)
if _incoming_pinned:
_merged_pinned = _merge_model_ids(
_normalize_model_ids(getattr(existing, "pinned_models", None)),
_incoming_pinned,
)
existing.pinned_models = json.dumps(_merged_pinned) if _merged_pinned else None
_db_dedup.commit()
_invalidate_models_cache()
_existing_pinned = _normalize_model_ids(getattr(existing, "pinned_models", None))
return { return {
"id": existing.id, "id": existing.id,
"name": existing.name, "name": existing.name,
"base_url": existing.base_url, "base_url": existing.base_url,
"models": json.loads(existing.cached_models) if existing.cached_models else [], "models": _visible_models(
getattr(existing, "cached_models", None),
getattr(existing, "hidden_models", None),
existing.pinned_models,
),
"pinned_models": _existing_pinned,
"online": True, "online": True,
"status": "online", "status": "online",
"existing": True, "existing": True,
@@ -1225,6 +1302,7 @@ def setup_model_routes(model_discovery):
try: try:
_st_raw = (supports_tools or "").strip().lower() _st_raw = (supports_tools or "").strip().lower()
_st = True if _st_raw in ("true", "1", "yes") else (False if _st_raw in ("false", "0", "no") else None) _st = True if _st_raw in ("true", "1", "yes") else (False if _st_raw in ("false", "0", "no") else None)
_pinned = _normalize_model_ids(pinned_models)
# Stamp owner so the picker only shows this endpoint to the admin # Stamp owner so the picker only shows this endpoint to the admin
# who added it. Pass `shared=true` to mark it null-owner (visible # who added it. Pass `shared=true` to mark it null-owner (visible
# to all users), preserving the pre-fix "everyone sees everything" # to all users), preserving the pre-fix "everyone sees everything"
@@ -1240,6 +1318,7 @@ def setup_model_routes(model_discovery):
is_enabled=True, is_enabled=True,
model_type=model_type.strip() if model_type else "llm", model_type=model_type.strip() if model_type else "llm",
cached_models=json.dumps(model_ids) if model_ids else None, cached_models=json.dumps(model_ids) if model_ids else None,
pinned_models=json.dumps(_pinned) if _pinned else None,
supports_tools=_st, supports_tools=_st,
owner=_owner_val, owner=_owner_val,
) )
@@ -1265,9 +1344,10 @@ def setup_model_routes(model_discovery):
"id": ep_id, "id": ep_id,
"name": name.strip(), "name": name.strip(),
"base_url": base_url, "base_url": base_url,
"models": model_ids, "models": _merge_model_ids(model_ids, _pinned),
"online": bool(model_ids) or bool(ping.get("reachable")), "pinned_models": _pinned,
"status": "online" if model_ids else ("empty" if ping.get("reachable") else "offline"), "online": bool(model_ids) or bool(_pinned) or bool(ping.get("reachable")),
"status": "online" if (model_ids or _pinned) else ("empty" if ping.get("reachable") else "offline"),
"ping_error": ping.get("error") if ping else None, "ping_error": ping.get("error") if ping else None,
} }
@@ -1360,7 +1440,8 @@ def setup_model_routes(model_discovery):
hidden = set(json.loads(ep.hidden_models)) hidden = set(json.loads(ep.hidden_models))
except Exception: except Exception:
pass pass
# Try live probe, fall back to cached # Try live probe, fall back to cached. Pinned IDs are admin-entered
# and persist regardless of probe results — never overwritten here.
all_models = _probe_endpoint(ep.base_url, ep.api_key, timeout=3) all_models = _probe_endpoint(ep.base_url, ep.api_key, timeout=3)
if all_models: if all_models:
ep.cached_models = json.dumps(all_models) ep.cached_models = json.dumps(all_models)
@@ -1370,18 +1451,28 @@ def setup_model_routes(model_discovery):
all_models = json.loads(ep.cached_models) all_models = json.loads(ep.cached_models)
except Exception: except Exception:
pass pass
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
pinned_set = set(pinned)
return [ return [
{"id": m, "display": m.split("/")[-1], "is_hidden": m in hidden} {
for m in all_models "id": m,
"display": m.split("/")[-1],
"is_hidden": m in hidden,
"is_pinned": m in pinned_set,
}
for m in _merge_model_ids(all_models, pinned)
] ]
finally: finally:
db.close() db.close()
@router.patch("/model-endpoints/{ep_id}/models") @router.patch("/model-endpoints/{ep_id}/models")
async def update_hidden_models(ep_id: str, request: Request): async def update_hidden_models(ep_id: str, request: Request):
"""Bulk update hidden models list for an endpoint. """Bulk update hidden and/or pinned model lists for an endpoint.
Expects JSON body: {"hidden": ["model-id-1", "model-id-2"]} Expects JSON body with optional keys:
{"hidden": ["model-id-1", ...], "pinned_models": ["deploy-id", ...]}
Each key is updated only when present, so callers can patch one list
without clobbering the other.
""" """
require_admin(request) require_admin(request)
db = SessionLocal() db = SessionLocal()
@@ -1390,13 +1481,22 @@ def setup_model_routes(model_discovery):
if not ep: if not ep:
raise HTTPException(404, "Endpoint not found") raise HTTPException(404, "Endpoint not found")
body = await request.json() body = await request.json()
hidden = body.get("hidden", []) if not isinstance(body, dict):
if not isinstance(hidden, list): raise HTTPException(400, "Body must be a JSON object")
raise HTTPException(400, "hidden must be a list of model IDs") if "hidden" in body:
ep.hidden_models = json.dumps(hidden) if hidden else None hidden = body.get("hidden")
if not isinstance(hidden, list):
raise HTTPException(400, "hidden must be a list of model IDs")
ep.hidden_models = json.dumps(hidden) if hidden else None
# Accept either "pinned" or "pinned_models" for the manual IDs list.
if "pinned_models" in body or "pinned" in body:
pinned = _normalize_model_ids(body.get("pinned_models", body.get("pinned")))
ep.pinned_models = json.dumps(pinned) if pinned else None
db.commit() db.commit()
_invalidate_models_cache() _invalidate_models_cache()
return {"id": ep_id, "hidden_count": len(hidden)} hidden_count = len(json.loads(ep.hidden_models)) if ep.hidden_models else 0
pinned_count = len(json.loads(ep.pinned_models)) if ep.pinned_models else 0
return {"id": ep_id, "hidden_count": hidden_count, "pinned_count": pinned_count}
finally: finally:
db.close() db.close()
@@ -1494,9 +1594,9 @@ def setup_model_routes(model_discovery):
return {"endpoint_id": "", "endpoint_url": "", "model": ""} return {"endpoint_id": "", "endpoint_url": "", "model": ""}
base = _normalize_base(ep.base_url) base = _normalize_base(ep.base_url)
chat_url = build_chat_url(base) chat_url = build_chat_url(base)
if not model and getattr(ep, "cached_models", None): if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)):
try: try:
visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None)) visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None))
if visible: if visible:
model = visible[0] model = visible[0]
except Exception: except Exception:
@@ -1532,6 +1632,9 @@ def setup_model_routes(model_discovery):
ep.name = body["name"].strip() or ep.name ep.name = body["name"].strip() or ep.name
if "model_type" in body and isinstance(body["model_type"], str): if "model_type" in body and isinstance(body["model_type"], str):
ep.model_type = body["model_type"].strip() or ep.model_type ep.model_type = body["model_type"].strip() or ep.model_type
if "pinned_models" in body:
_pinned = _normalize_model_ids(body["pinned_models"])
ep.pinned_models = json.dumps(_pinned) if _pinned else None
# Rotating an API key used to require DELETE+POST, which wiped # Rotating an API key used to require DELETE+POST, which wiped
# endpoint_url/model from every session referencing the old base # endpoint_url/model from every session referencing the old base
# URL. Allow in-place updates so the admin can change the key # URL. Allow in-place updates so the admin can change the key
@@ -1560,6 +1663,7 @@ def setup_model_routes(model_discovery):
"name": ep.name, "name": ep.name,
"model_type": ep.model_type, "model_type": ep.model_type,
"base_url": ep.base_url, "base_url": ep.base_url,
"pinned_models": _normalize_model_ids(getattr(ep, "pinned_models", None)),
} }
finally: finally:
db.close() db.close()
+26 -24
View File
@@ -9,7 +9,9 @@ import httpx
from fastapi import APIRouter, HTTPException, Request, Form from fastapi import APIRouter, HTTPException, Request, Form
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from core.database import SessionLocal, Webhook from core.database import SessionLocal, Webhook, ModelEndpoint
from src.auth_helpers import owner_filter
from src.url_security import validate_public_http_url
from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -26,23 +28,19 @@ MAX_MESSAGE_LEN = 32_000
from core.middleware import require_admin as _require_admin from core.middleware import require_admin as _require_admin
def _first_enabled_endpoint(db, owner): def _select_api_chat_fallback_endpoint(db, token_owner: Optional[str]):
"""First enabled ModelEndpoint VISIBLE to `owner` — their own rows plus """First enabled ModelEndpoint visible to token_owner — their own rows plus
legacy null-owner ("shared") rows. Owner-scoped on purpose: ModelEndpoint legacy null-owner ("shared") rows. Owner-scoped: an unscoped .first() would
is per-user (core/database.py — "when non-null, the model picker only shows let a chat-scoped token fall back onto another user's private endpoint and
the endpoint to that user"), and the sync-chat fallback uses the row's silently spend that owner's API key/quota. Prefer owner rows before shared
decrypted `api_key`. An unscoped ``.first()`` would let a chat-scoped token rows. Fails closed to null-owner rows only when token_owner is absent.
(e.g. a paired mobile device) fall back onto ANOTHER user's private Does not validate base_url — admin-configured local/LAN endpoints remain allowed.
endpoint and silently spend that owner's API key / quota — and reach
whatever internal base_url they configured. Mirrors the owner_filter scoping
in routes/model_routes.py and companion/routes.py. A null/empty owner is a
no-op (single-user / legacy mode), preserving the original behaviour.
""" """
from core.database import ModelEndpoint query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
from src.auth_helpers import owner_filter if token_owner:
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712 query = owner_filter(query, ModelEndpoint, token_owner)
q = owner_filter(q, ModelEndpoint, owner) return query.order_by(ModelEndpoint.owner.desc(), ModelEndpoint.created_at).first()
return q.first() return query.filter(ModelEndpoint.owner == None).order_by(ModelEndpoint.created_at).first() # noqa: E711
def _caller_owns_session(sess_owner, caller) -> bool: def _caller_owns_session(sess_owner, caller) -> bool:
@@ -278,15 +276,21 @@ def setup_webhook_routes(
api_key = body.api_key.strip() api_key = body.api_key.strip()
model = body.model or "deepseek-chat" model = body.model or "deepseek-chat"
# Resolve base_url: explicit > provider name > model prefix auto-detect # Validate only token-supplied direct base_url; auto-resolved known-provider
base_url = body.base_url.strip().rstrip("/") if body.base_url else None # URLs are not subject to extra local/LAN blocking beyond existing provider logic.
if not base_url: direct_base_url = body.base_url.strip().rstrip("/") if body.base_url else None
if direct_base_url:
try:
base_url = validate_public_http_url(direct_base_url)
except ValueError as e:
detail = str(e).replace("URL", "base_url", 1)
raise HTTPException(400, detail)
else:
base_url = _resolve_base_url(model, body.provider) base_url = _resolve_base_url(model, body.provider)
if not base_url: if not base_url:
raise HTTPException(400, raise HTTPException(400,
"Could not auto-detect provider. Pass base_url (e.g. 'https://api.deepseek.com/v1') " "Could not auto-detect provider. Pass base_url (e.g. 'https://api.deepseek.com/v1') "
"or provider ('deepseek', 'openai', 'groq', etc.)") "or provider ('deepseek', 'openai', 'groq', etc.)")
base_url = normalize_base(base_url) base_url = normalize_base(base_url)
endpoint_url = build_chat_url(base_url) endpoint_url = build_chat_url(base_url)
@@ -306,9 +310,7 @@ def setup_webhook_routes(
if not sess: if not sess:
db = SessionLocal() db = SessionLocal()
try: try:
# Owner-scoped: only THIS token owner's endpoints + legacy ep = _select_api_chat_fallback_endpoint(db, token_owner)
# shared rows, never another user's private endpoint/api_key.
ep = _first_enabled_endpoint(db, token_owner)
finally: finally:
db.close() db.close()
+113 -48
View File
@@ -5110,6 +5110,100 @@
"release_date": "2023-10-29", "release_date": "2023-10-29",
"_discovered": true "_discovered": true
}, },
{
"name": "deepseek-ai/DeepSeek-V4-Flash",
"provider": "deepseek-ai",
"parameter_count": "284B",
"parameters_raw": 284000000000,
"active_parameters": 13000000000,
"is_moe": true,
"min_ram_gb": 200.0,
"recommended_ram_gb": 320.0,
"min_vram_gb": 156.0,
"quantization": "FP4-MoE-Mixed",
"context_length": 1000000,
"use_case": "General-purpose reasoning, long-context",
"capabilities": [
"long_context",
"reasoning",
"moe"
],
"pipeline_tag": "text-generation",
"architecture": "deepseek_v4_moe",
"hf_downloads": 3542202,
"hf_likes": 0,
"release_date": "2026-05-15"
},
{
"name": "deepseek-ai/DeepSeek-V4-Flash-Base",
"provider": "deepseek-ai",
"parameter_count": "284B",
"parameters_raw": 284000000000,
"active_parameters": 13000000000,
"is_moe": true,
"min_ram_gb": 290.0,
"recommended_ram_gb": 460.0,
"min_vram_gb": 284.0,
"quantization": "FP8-Mixed",
"context_length": 1000000,
"use_case": "Base pretrained \u2014 fine-tuning starting point",
"capabilities": [
"long_context",
"moe"
],
"pipeline_tag": "text-generation",
"architecture": "deepseek_v4_moe",
"hf_downloads": 0,
"hf_likes": 0,
"release_date": "2026-05-15"
},
{
"name": "deepseek-ai/DeepSeek-V4-Pro",
"provider": "deepseek-ai",
"parameter_count": "1.6T",
"parameters_raw": 1600000000000,
"active_parameters": 49000000000,
"is_moe": true,
"min_ram_gb": 1100.0,
"recommended_ram_gb": 1800.0,
"min_vram_gb": 880.0,
"quantization": "FP4-MoE-Mixed",
"context_length": 1000000,
"use_case": "Flagship reasoning, long-context",
"capabilities": [
"long_context",
"reasoning",
"moe"
],
"pipeline_tag": "text-generation",
"architecture": "deepseek_v4_moe",
"hf_downloads": 0,
"hf_likes": 0,
"release_date": "2026-05-15"
},
{
"name": "deepseek-ai/DeepSeek-V4-Pro-Base",
"provider": "deepseek-ai",
"parameter_count": "1.6T",
"parameters_raw": 1600000000000,
"active_parameters": 49000000000,
"is_moe": true,
"min_ram_gb": 1700.0,
"recommended_ram_gb": 2600.0,
"min_vram_gb": 1600.0,
"quantization": "FP8-Mixed",
"context_length": 1000000,
"use_case": "Base pretrained \u2014 fine-tuning starting point",
"capabilities": [
"long_context",
"moe"
],
"pipeline_tag": "text-generation",
"architecture": "deepseek_v4_moe",
"hf_downloads": 0,
"hf_likes": 0,
"release_date": "2026-05-15"
},
{ {
"name": "deepseek-ai/deepseek-coder-6.7b-base", "name": "deepseek-ai/deepseek-coder-6.7b-base",
"provider": "DeepSeek", "provider": "DeepSeek",
@@ -12092,7 +12186,7 @@
"min_ram_gb": 421.3, "min_ram_gb": 421.3,
"recommended_ram_gb": 702.1, "recommended_ram_gb": 702.1,
"min_vram_gb": 386.1, "min_vram_gb": 386.1,
"quantization": "Q4_K_M", "quantization": "BF16",
"context_length": 202752, "context_length": 202752,
"use_case": "General purpose text generation", "use_case": "General purpose text generation",
"capabilities": [], "capabilities": [],
@@ -12102,6 +12196,24 @@
"hf_likes": 1698, "hf_likes": 1698,
"release_date": "2026-02-11" "release_date": "2026-02-11"
}, },
{
"name": "zai-org/GLM-5.1",
"provider": "zai-org",
"parameter_count": "753.9B",
"parameters_raw": 753864139008,
"min_ram_gb": 421.3,
"recommended_ram_gb": 702.1,
"min_vram_gb": 386.1,
"quantization": "BF16",
"context_length": 202752,
"use_case": "General purpose text generation",
"capabilities": [],
"pipeline_tag": "text-generation",
"architecture": "glm_moe_dsa",
"hf_downloads": 141194,
"hf_likes": 0,
"release_date": "2026-04-03"
},
{ {
"name": "moonshotai/Kimi-K2-Instruct", "name": "moonshotai/Kimi-K2-Instruct",
"provider": "moonshotai", "provider": "moonshotai",
@@ -13868,53 +13980,6 @@
"gguf_sources": [], "gguf_sources": [],
"capabilities": [] "capabilities": []
}, },
{
"name": "deepseek-ai/DeepSeek-V4-Flash",
"provider": "DeepSeek",
"parameter_count": "158B",
"parameters_raw": 158000000000,
"min_ram_gb": 165.0,
"recommended_ram_gb": 205.0,
"min_vram_gb": 165.0,
"quantization": "FP8",
"context_length": 1000000,
"use_case": "General purpose, reasoning (MoE)",
"is_moe": true,
"num_experts": null,
"active_experts": null,
"active_parameters": 13000000000,
"architecture": "deepseek_v4",
"pipeline_tag": "text-generation",
"release_date": "2026-04-22",
"gguf_sources": [
{
"repo": "unsloth/DeepSeek-V4-Flash",
"provider": "unsloth"
}
],
"capabilities": []
},
{
"name": "deepseek-ai/DeepSeek-V4-Pro",
"provider": "DeepSeek",
"parameter_count": "1600B",
"parameters_raw": 1600000000000,
"min_ram_gb": 928.5,
"recommended_ram_gb": 1207.0,
"min_vram_gb": 928.5,
"quantization": "Q4_K_M",
"context_length": 1000000,
"use_case": "Frontier reasoning (MoE)",
"is_moe": true,
"num_experts": null,
"active_experts": null,
"active_parameters": 49000000000,
"architecture": "deepseek_v4",
"pipeline_tag": "text-generation",
"release_date": "2026-04-22",
"gguf_sources": [],
"capabilities": []
},
{ {
"name": "google/gemma-4-E2B-it", "name": "google/gemma-4-E2B-it",
"provider": "Google", "provider": "Google",
+75 -14
View File
@@ -362,10 +362,24 @@ def analyze_model(model, system, target_quant=None, scoring_use_case=None, targe
elif target_quant: elif target_quant:
# User picked a specific quant # User picked a specific quant
quant_to_try = target_quant quant_to_try = target_quant
elif gpu_count >= 2:
# Multi-GPU box: vLLM/SGLang can't serve GGUF Q* quants (those are
# llama.cpp-only). Default non-prequantized models to BF16 so the row
# is meaningful on a multi-GPU rig. If BF16 doesn't fit, the model
# surfaces as too_tight — better than showing a Q4 row the user
# can't actually serve with vLLM on >1 GPU.
quant_to_try = "BF16"
else: else:
# Default: Q4_K_M (user's stated preference) # Default: Q4_K_M (user's stated preference) — kept for single-GPU
# and RAM modes where llama.cpp serving is the natural path.
quant_to_try = "Q4_K_M" quant_to_try = "Q4_K_M"
# Multi-GPU filter: skip the row if the resolved quant is a GGUF tier
# (Q*/IQ-prefixed) — vLLM/SGLang can't serve those, so showing them on
# a 2+ GPU rig just clutters the list with unservable candidates.
if gpu_count >= 2 and quant_to_try and quant_to_try.upper().startswith(("Q2", "Q3", "Q4", "Q5", "Q6", "Q8", "IQ")):
return None
result = _try_quant_at(model, quant_to_try, ctx, effective_vram, 0 if native_gpu_only else eff_ram) result = _try_quant_at(model, quant_to_try, ctx, effective_vram, 0 if native_gpu_only else eff_ram)
if result is None: if result is None:
@@ -457,8 +471,42 @@ def analyze_model(model, system, target_quant=None, scoring_use_case=None, targe
} }
def _version_key(name):
"""Parse the model's version number from its display name so equal-score
rows can break ties in favor of the newer release (e.g. M2.7 > M2.5).
Returns a float; 0.0 for names with no recognizable version. The regex
grabs the FIRST 'word-with-digits' pattern after a hyphen/underscore,
so e.g. 'MiniMax-M2.7' -> 2.7, 'Qwen3.6-35B' -> 3.6, 'M2' -> 2.0."""
import re as _re
if not name:
return 0.0
# Match the version-marker word: a letter followed by a number with
# optional decimal, e.g. M2.7, V4, Pro3. Take the first hit; ignore
# "B" param-count suffixes (Qwen3-235B should yield 3, not 235).
for m in _re.finditer(r"[A-Za-z](\d+(?:\.\d+)?)(?![A-Za-z])", name):
val = m.group(1)
# Skip param-count tokens (e.g. "235B" gives "235" but the next
# char would be "B" — already excluded by the negative lookahead).
try:
f = float(val)
except ValueError:
continue
# Heuristic: bare integers >= 100 are almost certainly param counts
# (1B/3B/8B/70B/235B…), not version numbers. Skip them.
if "." not in val and f >= 100:
continue
return f
return 0.0
SORT_KEYS = { SORT_KEYS = {
"score": lambda r: r["score"], # Score sort with version-aware tiebreaker — when two rows tie on
# composite score (a common case for the SAME base model in different
# versions, e.g. MiniMax-M2.5 vs M2.7 both at the same FP8 budget),
# prefer the newer version. Without this, ties resolved to whatever
# order they came out of the registry, which let older releases land
# above newer ones in user-facing lists.
"score": lambda r: (r["score"], _version_key(r.get("name") or "")),
"speed": lambda r: r["speed_tps"], "speed": lambda r: r["speed_tps"],
"vram": lambda r: r["required_gb"], "vram": lambda r: r["required_gb"],
"params": lambda r: r["params_b"], "params": lambda r: r["params_b"],
@@ -470,8 +518,14 @@ SORT_KEYS = {
} }
def rank_models(system, use_case=None, limit=50, search=None, sort="score", quant=None, target_context=None): def rank_models(system, use_case=None, limit=50, search=None, sort="score", quant=None, target_context=None, fit_only=False):
"""Rank all models against detected hardware. Returns sorted list of fit results.""" """Rank all models against detected hardware. Returns sorted list of fit results.
fit_only: when True, drop rows whose fit_level is "too_tight" (model doesn't
actually fit on the chosen budget). When False (default), every model is
shown — sorting by Param means highest-param PERIOD, even ones that won't
run, so the user can see the truth.
"""
models = get_models() models = get_models()
results = [] results = []
@@ -510,7 +564,7 @@ def rank_models(system, use_case=None, limit=50, search=None, sort="score", quan
}) })
if use_case == "image_gen": if use_case == "image_gen":
sort_fn = SORT_KEYS.get(sort, SORT_KEYS["score"]) sort_fn = SORT_KEYS.get(sort, SORT_KEYS["score"])
results.sort(key=sort_fn, reverse=(sort != "vram")) results.sort(key=sort_fn, reverse=True) # see main path below
return results[:limit] return results[:limit]
# If user picked a native prequantized format, filter to only those models. # If user picked a native prequantized format, filter to only those models.
@@ -596,14 +650,21 @@ def rank_models(system, use_case=None, limit=50, search=None, sort="score", quan
results.append(result) results.append(result)
# Pick the visible SET by best fit (score) first, so it stays the same no # Pick the visible SET by the REQUESTED column. Per-user feedback: sorting
# matter which column the user sorts by — otherwise sorting by params would # by Param should show the highest-param models PERIOD, not just those that
# truncate to the N biggest models (huge ones that don't even fit) while # already fit. Same for every other column. Models that don't fit are still
# sorting by vram showed the N smallest. Only AFTER choosing the set do we # in the list with their fit_level marking the constraint, so the user can
# order it by the requested column. # see the truth instead of a quietly-truncated view. Score sort is unchanged
results.sort(key=SORT_KEYS["score"], reverse=True) # (it's the default ranking and naturally pushes non-fits to the bottom).
results = results[:limit] if fit_only:
# Hide rows that definitely don't fit (the "too_tight" badge) — user
# explicitly asked for a Fit-only view.
results = [r for r in results if r.get("fit_level") != "too_tight"]
sort_fn = SORT_KEYS.get(sort, SORT_KEYS["score"]) sort_fn = SORT_KEYS.get(sort, SORT_KEYS["score"])
# vram ascending (smallest first), everything else descending (biggest first) # Always sort descending then truncate top-N so each column shows the
results.sort(key=sort_fn, reverse=(sort != "vram")) # global highest by that metric. Before, vram was special-cased
# ascending → truncate kept the 50 SMALLEST models and "highest VRAM"
# could never appear, breaking the column-click toggle.
results.sort(key=sort_fn, reverse=True)
results = results[:limit]
return results return results
+3 -1
View File
@@ -5,7 +5,9 @@ import shutil
import subprocess import subprocess
import time import time
CACHE_TTL = 1800 # 30 min — hardware rarely changes; use the Rescan button to force a re-probe CACHE_TTL = 24 * 3600 # 24 h — hardware probes are user-initiated via the Rescan button; bumped
# from 30 min so changing filters doesn't keep re-probing the rig every
# half-hour during a long session.
_remote_host = None # set by detect_system(host=...) _remote_host = None # set by detect_system(host=...)
+24 -3
View File
@@ -13,6 +13,13 @@ QUANT_BPP = {
"AWQ-4bit": 0.50, "AWQ-8bit": 1.0, "AWQ-4bit": 0.50, "AWQ-8bit": 1.0,
"GPTQ-Int4": 0.50, "GPTQ-Int8": 1.0, "GPTQ-Int4": 0.50, "GPTQ-Int8": 1.0,
"mlx-4bit": 0.55, "mlx-8bit": 1.0, "mlx-6bit": 0.75, "mlx-4bit": 0.55, "mlx-8bit": 1.0, "mlx-6bit": 0.75,
# DeepSeek-V4-style mixed: MoE experts in FP4 (bulk), attention + non-
# expert dense in FP8, embeddings/LM head in BF16. By weight count the
# experts dominate so the effective BPP sits closer to FP4 than FP8.
# Empirical: DeepSeek-V4-Flash 284B / 156 GB ≈ 0.55 B/param.
"FP4-MoE-Mixed": 0.55,
# FP8-Mixed = the *-Base variants (MoE experts also FP8, not FP4).
"FP8-Mixed": 1.0,
} }
QUANT_SPEED_MULT = { QUANT_SPEED_MULT = {
@@ -24,6 +31,8 @@ QUANT_SPEED_MULT = {
"AWQ-4bit": 1.2, "AWQ-8bit": 0.85, "AWQ-4bit": 1.2, "AWQ-8bit": 0.85,
"GPTQ-Int4": 1.2, "GPTQ-Int8": 0.85, "GPTQ-Int4": 1.2, "GPTQ-Int8": 0.85,
"mlx-4bit": 1.15, "mlx-8bit": 0.85, "mlx-6bit": 1.0, "mlx-4bit": 1.15, "mlx-8bit": 0.85, "mlx-6bit": 1.0,
"FP4-MoE-Mixed": 1.10, # slightly slower than pure FP4 because of mixed-dtype dispatch
"FP8-Mixed": 0.85,
} }
QUANT_QUALITY_PENALTY = { QUANT_QUALITY_PENALTY = {
@@ -32,9 +41,18 @@ QUANT_QUALITY_PENALTY = {
"INT4": -4.0, "INT8": 0.0, "W4A16": -4.0, "W8A8": 0.0, "W8A16": 0.0, "INT4": -4.0, "INT8": 0.0, "W4A16": -4.0, "W8A8": 0.0, "W8A16": 0.0,
"Q8_0": 0.0, "Q6_K": -1.0, "Q5_K_M": -2.0, "Q8_0": 0.0, "Q6_K": -1.0, "Q5_K_M": -2.0,
"Q4_K_M": -5.0, "Q4_0": -5.0, "Q3_K_M": -8.0, "Q2_K": -12.0, "Q4_K_M": -5.0, "Q4_0": -5.0, "Q3_K_M": -8.0, "Q2_K": -12.0,
"AWQ-4bit": -3.0, "AWQ-8bit": 0.0, # Bare "AWQ" and "AWQ-8bit" used to be 0.0 (tied with FP8). In practice
"GPTQ-Int4": -3.0, "GPTQ-Int8": 0.0, # AWQ-anything is a calibrated reconstruction, not raw 8-bit weights —
"mlx-4bit": -4.0, "mlx-8bit": 0.0, "mlx-6bit": -1.0, # there's a small but real quality loss vs FP8. Give them a slight
# penalty so FP8 wins when both fit. AWQ-4bit stays heavier.
"AWQ": -1.0, "AWQ-4bit": -4.0, "AWQ-8bit": -1.0,
"GPTQ": -1.0, "GPTQ-Int4": -4.0, "GPTQ-Int8": -1.0,
"mlx-4bit": -4.0, "mlx-8bit": -0.5, "mlx-6bit": -1.5,
# DeepSeek-V4 mixed: only MoE experts at FP4 (the rest is FP8/BF16),
# so the realized quality is much closer to FP8 than to pure FP4 —
# the activation-sensitive layers stay high-precision. ~0 penalty.
"FP4-MoE-Mixed": -0.5,
"FP8-Mixed": 0.0,
} }
QUANT_BYTES_PER_PARAM = { QUANT_BYTES_PER_PARAM = {
@@ -46,6 +64,8 @@ QUANT_BYTES_PER_PARAM = {
"AWQ-4bit": 0.5, "AWQ-8bit": 1.0, "AWQ-4bit": 0.5, "AWQ-8bit": 1.0,
"GPTQ-Int4": 0.5, "GPTQ-Int8": 1.0, "GPTQ-Int4": 0.5, "GPTQ-Int8": 1.0,
"mlx-4bit": 0.5, "mlx-8bit": 1.0, "mlx-6bit": 0.75, "mlx-4bit": 0.5, "mlx-8bit": 1.0, "mlx-6bit": 0.75,
"FP4-MoE-Mixed": 0.55,
"FP8-Mixed": 1.0,
} }
# Pre-quantized formats that should NOT go through the GGUF quant hierarchy. # Pre-quantized formats that should NOT go through the GGUF quant hierarchy.
@@ -53,6 +73,7 @@ QUANT_BYTES_PER_PARAM = {
PREQUANTIZED_PREFIXES = ( PREQUANTIZED_PREFIXES = (
"AWQ-", "GPTQ-", "mlx-", "FP8", "FP4", "NVFP4", "MXFP4", "NF4", "AWQ-", "GPTQ-", "mlx-", "FP8", "FP4", "NVFP4", "MXFP4", "NF4",
"INT4", "INT8", "W4A16", "W8A8", "W8A16", "INT4", "INT8", "W4A16", "W8A8", "W8A16",
"FP4-MoE-Mixed", "FP8-Mixed",
) )
+38 -19
View File
@@ -2,12 +2,49 @@
import re import re
import logging import logging
from datetime import datetime from datetime import datetime, timezone
from typing import List, Optional from typing import List, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_AGE_FORMATS = ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S")
def _utcnow_naive() -> datetime:
"""Naive UTC 'now'. Matches the naive, UTC-style published dates parsed below,
and is safe on Python 3.14 where ``datetime.utcnow()`` is removed (#1116)."""
return datetime.now(timezone.utc).replace(tzinfo=None)
def recency_score(age_str: Optional[str], now: Optional[datetime] = None) -> float:
"""Score how recent a result is: 1.0 for <=7 days old, 0.0 for >=30 days.
The age is measured against UTC, not local time. The previous code used
``datetime.now()`` (local) against UTC-style published dates, so the age was
skewed by the host's UTC offset; it was also a latent crash once neighbouring
code moves to timezone-aware datetimes (#1116). ``now`` is injectable for tests.
"""
if not age_str:
return 0.0
dt = None
for fmt in _AGE_FORMATS:
try:
dt = datetime.strptime(age_str, fmt)
break
except Exception:
dt = None
if not dt:
return 0.0
now = now or _utcnow_naive()
days_old = (now - dt).days
if days_old <= 7:
return 1.0
if days_old >= 30:
return 0.0
return (30 - days_old) / 23
_NEWS_HINTS = {"news", "nyheter", "headlines", "breaking", "latest", "today", "idag"} _NEWS_HINTS = {"news", "nyheter", "headlines", "breaking", "latest", "today", "idag"}
_SPORTS_HINTS = { _SPORTS_HINTS = {
"sport", "sports", "soccer", "football", "hockey", "nba", "nfl", "mlb", "sport", "sports", "soccer", "football", "hockey", "nba", "nfl", "mlb",
@@ -73,24 +110,6 @@ def rank_search_results(query: str, results: List[dict]) -> List[dict]:
return 0.7 return 0.7
return 0.4 return 0.4
def recency_score(age_str: Optional[str]) -> float:
if not age_str:
return 0.0
for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"):
try:
dt = datetime.strptime(age_str, fmt)
break
except Exception:
dt = None
if not dt:
return 0.0
days_old = (datetime.now() - dt).days
if days_old <= 7:
return 1.0
if days_old >= 30:
return 0.0
return (30 - days_old) / 23
def news_quality_adjustment(title: str, snippet: str, url: str) -> float: def news_quality_adjustment(title: str, snippet: str, url: str) -> float:
if not is_news_query: if not is_news_query:
return 0.0 return 0.0
+10 -148
View File
@@ -1,151 +1,13 @@
"""Search result ranking based on relevance, source quality, and recency.""" """Compatibility re-export shim for the live ranking module.
import re The real implementation lives in :mod:`services.search.ranking`, which is what
import logging the search runtime (services/search/core.py) imports. This module used to hold a
from datetime import datetime, timezone parallel copy; it now re-exports so the two cannot drift out of sync again.
from typing import List, Optional """
from urllib.parse import urlparse
logger = logging.getLogger(__name__) from services.search.ranking import ( # noqa: F401
_AGE_FORMATS,
_AGE_FORMATS = ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S") _utcnow_naive,
rank_search_results,
recency_score,
def _utcnow_naive() -> datetime:
"""Naive UTC 'now'. Matches the naive, UTC-style published dates parsed below,
and is safe on Python 3.14 where ``datetime.utcnow()`` is removed (#1116)."""
return datetime.now(timezone.utc).replace(tzinfo=None)
def recency_score(age_str: Optional[str], now: Optional[datetime] = None) -> float:
"""Score how recent a result is: 1.0 for <=7 days old, 0.0 for >=30 days.
The age is measured against UTC, not local time. The previous code used
``datetime.now()`` (local) against UTC-style published dates, so the age was
skewed by the host's UTC offset; it was also a latent crash once neighbouring
code moves to timezone-aware datetimes (#1116). ``now`` is injectable for tests.
"""
if not age_str:
return 0.0
dt = None
for fmt in _AGE_FORMATS:
try:
dt = datetime.strptime(age_str, fmt)
break
except Exception:
dt = None
if not dt:
return 0.0
now = now or _utcnow_naive()
days_old = (now - dt).days
if days_old <= 7:
return 1.0
if days_old >= 30:
return 0.0
return (30 - days_old) / 23
_NEWS_HINTS = {"news", "nyheter", "headlines", "breaking", "latest", "today", "idag"}
_SPORTS_HINTS = {
"sport", "sports", "soccer", "football", "hockey", "nba", "nfl", "mlb",
"fifa", "world cup", "championship", "quarterfinal", "eliminates",
}
# Word-boundary match so "sport" does not fire inside "transport"/"passport"
# and a domain like "transport.gov" is not mistaken for a sports site.
_SPORTS_HINT_RE = re.compile(
r"\b(?:" + "|".join(re.escape(h) for h in _SPORTS_HINTS) + r")\b"
) )
_LOW_VALUE_NEWS_DOMAINS = {
"facebook.com", "www.facebook.com", "sports.yahoo.com", "yahoo.com",
"www.yahoo.com", "msn.com", "www.msn.com",
}
_TRUSTED_NEWS_DOMAINS = {
"apnews.com", "www.apnews.com", "reuters.com", "www.reuters.com",
"bbc.com", "www.bbc.com", "cbc.ca", "www.cbc.ca",
"ctvnews.ca", "www.ctvnews.ca", "globalnews.ca", "www.globalnews.ca",
"theguardian.com",
"www.theguardian.com", "euronews.com", "www.euronews.com",
"dw.com", "www.dw.com", "government.se", "www.government.se",
}
def _domain(url: str) -> str:
try:
return urlparse(url).netloc.lower()
except Exception:
return ""
def rank_search_results(query: str, results: List[dict]) -> List[dict]:
"""Rank search results by title relevance, snippet quality, domain authority, and recency."""
query_terms = [t.lower() for t in re.findall(r"\b\w+\b", query)]
query_lc = query.lower()
is_news_query = any(term in _NEWS_HINTS for term in query_terms)
is_sports_query = bool(_SPORTS_HINT_RE.search(query_lc))
def title_score(title: str) -> float:
if not title:
return 0.0
title_lc = title.lower()
matches = sum(1 for term in query_terms if re.search(rf"\b{re.escape(term)}\b", title_lc))
return matches / len(query_terms) if query_terms else 0.0
def snippet_score(snippet: str) -> float:
if not snippet:
return 0.0
length_factor = min(len(snippet), 200) / 200
term_hits = sum(1 for term in query_terms if term in snippet.lower())
term_factor = term_hits / len(query_terms) if query_terms else 0.0
return (length_factor + term_factor) / 2
def domain_score(url: str) -> float:
netloc = _domain(url)
if not netloc:
return 0.0
if netloc in _TRUSTED_NEWS_DOMAINS:
return 1.0
if netloc.endswith(".edu") or netloc.endswith(".gov"):
return 1.0
if netloc.endswith(".org"):
return 0.7
return 0.4
def news_quality_adjustment(title: str, snippet: str, url: str) -> float:
if not is_news_query:
return 0.0
text = f"{title} {snippet}".lower()
netloc = _domain(url)
adjustment = 0.0
if netloc in _TRUSTED_NEWS_DOMAINS:
adjustment += 1.2
if any(term in text for term in ("latest news", "breaking news", "daily coverage", "news from")):
adjustment += 0.4
if netloc in _LOW_VALUE_NEWS_DOMAINS:
adjustment -= 0.8
if not is_sports_query and (_SPORTS_HINT_RE.search(text) or _SPORTS_HINT_RE.search(netloc)):
adjustment -= 1.5
# A country/news query should not rank a page whose title/snippet barely
# mentions the country above actual news pages for that country.
subject_terms = [t for t in query_terms if t not in _NEWS_HINTS]
if subject_terms and not any(t in text or t in netloc for t in subject_terms):
adjustment -= 1.0
return adjustment
ranked = []
for result in results:
title = result.get("title", "")
snippet = result.get("snippet", "")
url = result.get("url", "")
age = result.get("age", None)
score = (
2.0 * title_score(title)
+ 1.0 * snippet_score(snippet)
+ 1.5 * domain_score(url)
+ 1.0 * recency_score(age)
+ news_quality_adjustment(title, snippet, url)
)
ranked.append((score, result))
ranked.sort(key=lambda x: x[0], reverse=True)
return [r for _, r in ranked]
+94
View File
@@ -0,0 +1,94 @@
"""URL validation helpers for server-side outbound requests."""
from __future__ import annotations
import ipaddress
import socket
from urllib.parse import urlparse
_INTERNAL_HOSTNAMES = {
"localhost",
"metadata",
"metadata.google.internal",
}
_INTERNAL_SUFFIXES = (
".localhost",
".local",
".internal",
".lan",
".intranet",
)
_BLOCKED_NETWORKS = (
ipaddress.ip_network("0.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("100.64.0.0/10"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("::/128"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
)
def _resolve_hostname_ips(hostname: str) -> list[ipaddress._BaseAddress]:
ips: list[ipaddress._BaseAddress] = []
for family, _, _, _, sockaddr in socket.getaddrinfo(hostname, None):
if family in (socket.AF_INET, socket.AF_INET6):
ips.append(ipaddress.ip_address(sockaddr[0]))
return ips
def _blocked_ip(addr: ipaddress._BaseAddress) -> bool:
return (
any(addr in net for net in _BLOCKED_NETWORKS)
or addr.is_private
or addr.is_loopback
or addr.is_link_local
or addr.is_multicast
or addr.is_unspecified
or addr.is_reserved
)
def _host_resolves_publicly(hostname: str) -> bool:
host = hostname.strip().lower()
if host in _INTERNAL_HOSTNAMES or host.endswith(_INTERNAL_SUFFIXES):
return False
try:
return not _blocked_ip(ipaddress.ip_address(host))
except ValueError:
pass
try:
addrs = _resolve_hostname_ips(host)
except OSError:
return False
return bool(addrs) and all(not _blocked_ip(addr) for addr in addrs)
def is_public_http_url(url: str) -> bool:
parsed = urlparse((url or "").strip())
if parsed.scheme not in ("http", "https") or not parsed.hostname:
return False
return _host_resolves_publicly(parsed.hostname)
def validate_public_http_url(url: str, *, max_length: int = 2048) -> str:
"""Validate a user/API-token supplied server-side HTTP(S) endpoint.
This is for untrusted outbound URLs, not admin-created model endpoints
that are intentionally allowed to point at private model providers. DNS
failures fail closed, and DNS checks reduce obvious private-network
targets but do not eliminate every DNS rebinding race by themselves.
"""
cleaned = (url or "").strip()
if len(cleaned) > max_length:
raise ValueError("URL is too long")
if not is_public_http_url(cleaned):
raise ValueError("URL must point to a public HTTP(S) endpoint")
return cleaned
+1 -1
View File
@@ -843,7 +843,7 @@
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/> <path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>
</svg> </svg>
<span class="grow">Cookbook</span> <span class="grow">Cookbook</span>
<span id="cookbook-bg-status" style="display:none;font-size:9px;opacity:0.5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-left:6px;flex-shrink:1;min-width:0;position:relative;top:-1px;"></span> <span id="cookbook-bg-status" style="display:none;font-size:9px;opacity:0.5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-right:12px;flex-shrink:1;min-width:0;position:relative;top:-1px;"></span>
<span class="cookbook-notif-dot" id="cookbook-notif-dot" style="display:none;margin-left:6px;margin-right:4px;position:relative;top:-1px;left:0px;"></span> <span class="cookbook-notif-dot" id="cookbook-notif-dot" style="display:none;margin-left:6px;margin-right:4px;position:relative;top:-1px;left:0px;"></span>
</div> </div>
<div class="list-item" id="tool-research-btn"> <div class="list-item" id="tool-research-btn">
+50 -62
View File
@@ -23,6 +23,44 @@ import {
// browser loads it once. See cookbook-hwfit.js. // browser loads it once. See cookbook-hwfit.js.
} from './cookbook.js'; } from './cookbook.js';
import uiModule from './ui.js'; import uiModule from './ui.js';
// Tiny HTML-escape — keeps the file standalone instead of leaning on a
// shared helper that may not be exported from this module's import surface.
function _diagEsc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// Pick an icon for a diagnosis-action button based on the label. The icon
// renders on the LEFT of the button text. Keeps the strokes consistent
// across the set so they read as one family.
function _diagFixIcon(label) {
const l = String(label || '').toLowerCase();
const _svg = (path) => `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" class="cookbook-diag-btn-ico" aria-hidden="true">${path}</svg>`;
if (l.startsWith('retry') || l.includes('relaunch') || l.includes('restart')) {
// Circular-arrow refresh
return _svg('<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>');
}
if (l.startsWith('copy')) {
return _svg('<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>');
}
if (l.startsWith('edit')) {
return _svg('<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/>');
}
if (l.startsWith('open') || l.includes('dependencies')) {
return _svg('<path d="M14 3h7v7"/><path d="M21 3l-9 9"/><path d="M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5"/>');
}
if (l.startsWith('install') || l.includes('upgrade')) {
return _svg('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>');
}
if (l.startsWith('kill') || l.startsWith('stop')) {
return _svg('<rect x="6" y="6" width="12" height="12" rx="1"/>');
}
if (l.startsWith('switch') || l.includes('use ')) {
return _svg('<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>');
}
// Default: lightbulb (generic "suggestion")
return _svg('<path d="M9 21h6"/><path d="M12 17v4"/><path d="M12 3a6 6 0 0 0-4 10.5c1 1 1.5 2 1.5 3.5h5c0-1.5.5-2.5 1.5-3.5A6 6 0 0 0 12 3Z"/>');
}
import spinnerModule from './spinner.js'; import spinnerModule from './spinner.js';
// ── Error diagnosis ── // ── Error diagnosis ──
@@ -120,17 +158,12 @@ export const ERROR_PATTERNS = [
}, },
{ {
pattern: /not divisible by weight quantization|quantization block/i, pattern: /not divisible by weight quantization|quantization block/i,
message: 'Model quantization format incompatible with this vLLM version. Try a different quant (AWQ) or update vLLM.', message: 'FP8 MoE quantization is incompatible with this tensor-parallel split.',
suggestion: 'Suggested action: retry with a lower tensor-parallel size, such as TP=4 or TP=2. If it still fails, use a non-FP8/GGUF version of the model.',
fixes: [ fixes: [
{ label: 'Update vLLM on server', action: (panel) => { { label: 'Retry with TP=4', action: (panel) => _serveAutoRetryReplace(panel, '--tensor-parallel-size', '4') },
const taskEl = panel.closest('.cookbook-task'); { label: 'Retry with TP=2', action: (panel) => _serveAutoRetryReplace(panel, '--tensor-parallel-size', '2') },
const task = taskEl ? _loadTasks().find(t => t.sessionId === taskEl.dataset.taskId) : null; { label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
const host = task?.remoteHost || '';
const prefix = _buildEnvPrefix();
const pipCmd = prefix ? prefix + ' pip install -U vllm' : 'pip install -U vllm';
const cmd = host ? _sshCmd(host, pipCmd) : pipCmd;
_launchServeTask('update-vllm', 'pip-update', cmd);
}},
], ],
}, },
{ {
@@ -528,53 +561,14 @@ export function _showDiagnosis(panel, diagnosis, sourceText) {
? `Suggested action: ${fixes[0].label}.` ? `Suggested action: ${fixes[0].label}.`
: 'Suggested action: copy the error and adjust the serve settings.'); : 'Suggested action: copy the error and adjust the serve settings.');
const header = document.createElement('div'); // Simplified diagnosis card: just the error message + suggestion + fix
header.className = 'cookbook-diag-header'; // button(s). Removed the fold toggle, copy button, and × dismiss — they
// made the card noisy without earning their keep. _diagCollapsed is kept
const fold = document.createElement('button'); // as a stub so callers don't have to change.
fold.className = 'cookbook-diag-fold'; panel._diagCollapsed = false;
fold.type = 'button';
fold.innerHTML = '<span class="cookbook-diag-chevron">▾</span><span>Error message:</span>';
header.appendChild(fold);
const copy = document.createElement('button');
copy.className = 'cookbook-diag-copy';
copy.type = 'button';
copy.title = 'Copy troubleshooting bundle';
copy.setAttribute('aria-label', 'Copy troubleshooting bundle');
copy.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
copy.addEventListener('click', (e) => {
e.stopPropagation();
_copyText(_diagnosisCopyBundle(task, diagnosis, sourceText, suggestionText));
copy.classList.add('copied');
copy.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
setTimeout(() => {
if (!copy.isConnected) return;
copy.classList.remove('copied');
copy.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
}, 1200);
});
header.appendChild(copy);
const dismiss = document.createElement('button');
dismiss.className = 'cookbook-diag-dismiss';
dismiss.type = 'button';
dismiss.title = 'Dismiss error';
dismiss.setAttribute('aria-label', 'Dismiss error');
dismiss.textContent = '×';
dismiss.addEventListener('click', (e) => {
e.stopPropagation();
panel._diagDismissed = diagnosis.message;
_clearDiagnosis(panel);
});
header.appendChild(dismiss);
diag.appendChild(header);
const body = document.createElement('div'); const body = document.createElement('div');
body.className = 'cookbook-diag-body'; body.className = 'cookbook-diag-body';
body.classList.toggle('hidden', panel._diagCollapsed);
fold.querySelector('.cookbook-diag-chevron').textContent = panel._diagCollapsed ? '▸' : '▾';
const msg = document.createElement('div'); const msg = document.createElement('div');
msg.className = 'cookbook-diag-message'; msg.className = 'cookbook-diag-message';
msg.textContent = diagnosis.message; msg.textContent = diagnosis.message;
@@ -583,12 +577,6 @@ export function _showDiagnosis(panel, diagnosis, sourceText) {
suggestion.className = 'cookbook-diag-suggestion'; suggestion.className = 'cookbook-diag-suggestion';
suggestion.textContent = suggestionText; suggestion.textContent = suggestionText;
body.appendChild(suggestion); body.appendChild(suggestion);
fold.addEventListener('click', (e) => {
e.stopPropagation();
panel._diagCollapsed = !panel._diagCollapsed;
body.classList.toggle('hidden', panel._diagCollapsed);
fold.querySelector('.cookbook-diag-chevron').textContent = panel._diagCollapsed ? '▸' : '▾';
});
diag.appendChild(body); diag.appendChild(body);
const runFix = async (fix, button, busyLabel = fix.label, onStart = null, onDone = null) => { const runFix = async (fix, button, busyLabel = fix.label, onStart = null, onDone = null) => {
@@ -627,7 +615,7 @@ export function _showDiagnosis(panel, diagnosis, sourceText) {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.className = 'cookbook-btn cookbook-diag-btn'; btn.className = 'cookbook-btn cookbook-diag-btn';
btn.type = 'button'; btn.type = 'button';
btn.textContent = fix.label; btn.innerHTML = _diagFixIcon(fix.label) + '<span class="cookbook-diag-btn-label">' + _diagEsc(fix.label) + '</span>';
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
runFix(fix, btn); runFix(fix, btn);
@@ -653,7 +641,7 @@ export function _showDiagnosis(panel, diagnosis, sourceText) {
for (const fix of fixes) { for (const fix of fixes) {
const item = document.createElement('button'); const item = document.createElement('button');
item.type = 'button'; item.type = 'button';
item.textContent = fix.label; item.innerHTML = _diagFixIcon(fix.label) + '<span class="cookbook-diag-btn-label">' + _diagEsc(fix.label) + '</span>';
item.addEventListener('click', async (e) => { item.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
if (item.dataset.busy || trigger.dataset.busy) return; if (item.dataset.busy || trigger.dataset.busy) return;
+109 -11
View File
@@ -153,14 +153,31 @@ export function _renderGpuToggles(system) {
} }
const validCounts = _validTpCounts(poolSize); const validCounts = _validTpCounts(poolSize);
const maxGpu = validCounts.length ? validCounts[validCounts.length - 1] : 0; const maxGpu = validCounts.length ? validCounts[validCounts.length - 1] : 0;
// Commit the data layer to maxGpu on initial render so it matches the
// visual highlight. Before this, _activeCount stayed undefined → no
// gpu_count param sent → backend's fallback could rank against RAM on
// mixed-resource boxes ("tightest" sorted by RAM instead of GPU).
if (container._activeCount === undefined && validCounts.length) {
container._activeCount = maxGpu;
}
html += '<button class="hwfit-gpu-btn" data-count="0" title="CPU / RAM only">RAM</button>'; html += '<button class="hwfit-gpu-btn" data-count="0" title="CPU / RAM only">RAM</button>';
const hasExplicitCount = typeof container._activeCount === 'number'; const hasExplicitCount = typeof container._activeCount === 'number';
for (const n of validCounts) { for (const n of validCounts) {
const text = n === 1 ? 'GPU' : n + ' GPU'; const text = n === 1 ? 'GPU' : n + ' GPU';
const isActive = hasExplicitCount ? (n === container._activeCount) : (container._activeCount === undefined && n === maxGpu); const isActive = hasExplicitCount && n === container._activeCount;
html += `<button class="hwfit-gpu-btn${isActive ? ' active' : ''}" data-count="${n}" title="${n} GPU${n > 1 ? 's' : ''}">${text}</button>`; html += `<button class="hwfit-gpu-btn${isActive ? ' active' : ''}" data-count="${n}" title="${n} GPU${n > 1 ? 's' : ''}">${text}</button>`;
} }
// Also mark the RAM button active when the user explicitly chose RAM (0)
// — the loop above only handles GPU buttons.
if (container._activeCount === 0) {
const ramBtn = container.querySelector('.hwfit-gpu-btn[data-count="0"]');
// (we just set innerHTML so we re-mark below after assignment)
}
container.innerHTML = html; container.innerHTML = html;
if (container._activeCount === 0) {
const ramBtn = container.querySelector('.hwfit-gpu-btn[data-count="0"]');
if (ramBtn) ramBtn.classList.add('active');
}
// Pool dropdown: switch pools, reset the count to the new pool's max, rebuild. // Pool dropdown: switch pools, reset the count to the new pool's max, rebuild.
const sel = container.querySelector('#hwfit-gpu-group'); const sel = container.querySelector('#hwfit-gpu-group');
@@ -188,9 +205,12 @@ export function _renderGpuToggles(system) {
} else { } else {
btn.classList.add('active'); btn.classList.add('active');
container._activeCount = count; container._activeCount = count;
// Auto-set quant based on hardware selection // Auto-suggest a quant based on hardware selection — but ONLY when the
// user has already picked a specific quant. When they're on "All"
// (value === ""), leave them on All: toggling a GPU shouldn't silently
// yank them out of the All view they wanted to see.
const quantSel = document.getElementById('hwfit-quant'); const quantSel = document.getElementById('hwfit-quant');
if (quantSel) { if (quantSel && quantSel.value !== '') {
if (count <= 1) { if (count <= 1) {
quantSel.value = 'Q4_K_M'; // RAM or 1 GPU -> Q4 sweet spot quantSel.value = 'Q4_K_M'; // RAM or 1 GPU -> Q4 sweet spot
} else if (String(system?.backend || '').toLowerCase() === 'rocm') { } else if (String(system?.backend || '').toLowerCase() === 'rocm') {
@@ -218,6 +238,9 @@ const _CTX_PRESETS = [8192, 16384, 32768, 50000, 131072, 0]; // 0 = model max
const _SCAN_CACHE_MAX = 12; // keep the newest N signatures const _SCAN_CACHE_MAX = 12; // keep the newest N signatures
const _SCAN_CACHE_TTL = 6 * 3600 * 1000; // 6 h — hardware rarely changes const _SCAN_CACHE_TTL = 6 * 3600 * 1000; // 6 h — hardware rarely changes
// Ctx slider helpers (ported from origin/main). The slider picks an INDEX into
// _CTX_PRESETS; _ctxValue() resolves it to a token count (0 = "Max"). The label
// next to the slider re-renders to "8k" / "16k" / … / "Max".
function _ctxLabel(value) { function _ctxLabel(value) {
const n = Number(value) || 0; const n = Number(value) || 0;
if (!n) return 'Max'; if (!n) return 'Max';
@@ -449,7 +472,9 @@ export async function _hwfitFetch(fresh = false) {
fetch(`/api/model/cached?${_cacheParams}`, { credentials: 'same-origin' }) fetch(`/api/model/cached?${_cacheParams}`, { credentials: 'same-origin' })
.then(r => r.json()) .then(r => r.json())
.then(d => { .then(d => {
_cachedModelIds = new Set((d.models || []).map(m => m.repo_id)); // Exclude stalled (download-shell) entries — a 12 KB README-only
// folder shouldn't count as "downloaded" in the Scan/Download list.
_cachedModelIds = new Set((d.models || []).filter(m => m.status !== 'stalled').map(m => m.repo_id));
// Re-mark rows if already rendered // Re-mark rows if already rendered
list.querySelectorAll('.hwfit-row[data-model]').forEach(row => { list.querySelectorAll('.hwfit-row[data-model]').forEach(row => {
const name = row.dataset.model; const name = row.dataset.model;
@@ -502,6 +527,9 @@ export async function _hwfitFetch(fresh = false) {
if (useCase) params.set('use_case', useCase); if (useCase) params.set('use_case', useCase);
if (quantPref) params.set('quant', quantPref); if (quantPref) params.set('quant', quantPref);
if (targetCtx) params.set('ctx', String(targetCtx)); if (targetCtx) params.set('ctx', String(targetCtx));
// Fit-only filter — set by the dot in the Fit column header.
const _fitOnly = (() => { try { return localStorage.getItem('hwfit_fit_only_v1') === '1'; } catch { return false; } })();
if (_fitOnly) params.set('fit_only', '1');
} }
const endpoint = isImageMode ? `/api/hwfit/image-models?${params}` : `/api/hwfit/models?${params}`; const endpoint = isImageMode ? `/api/hwfit/image-models?${params}` : `/api/hwfit/models?${params}`;
const res = await fetch(endpoint); const res = await fetch(endpoint);
@@ -845,6 +873,13 @@ export function _hwfitRenderList(el, models) {
const sortSel = document.getElementById('hwfit-sort'); const sortSel = document.getElementById('hwfit-sort');
const currentSort = sortSel?.value || 'score'; const currentSort = sortSel?.value || 'score';
const isReversed = sortSel?.dataset.reverse === '1'; const isReversed = sortSel?.dataset.reverse === '1';
// Active budget for the Fit column label \u2014 make it obvious whether the
// ranking is against GPU or RAM so "tightest" can't be ambiguous on a
// mixed-resource box.
const tc = document.getElementById('hwfit-gpu-toggles');
const _budget = (tc && typeof tc._activeCount === 'number')
? (tc._activeCount === 0 ? 'RAM' : (tc._activeCount === 1 ? 'GPU' : tc._activeCount + ' GPU'))
: null;
let html = '<div class="hwfit-row hwfit-header">'; let html = '<div class="hwfit-row hwfit-header">';
for (const col of _hwfitColumns) { for (const col of _hwfitColumns) {
const sortable = col.key ? ' hwfit-sortable' : ''; const sortable = col.key ? ' hwfit-sortable' : '';
@@ -856,7 +891,16 @@ export function _hwfitRenderList(el, models) {
arrow = isReversed ? ' \u25B2' : ' \u25BC'; arrow = isReversed ? ' \u25B2' : ' \u25BC';
} }
const dataAttr = col.key ? ` data-sort="${col.key}"` : ''; const dataAttr = col.key ? ` data-sort="${col.key}"` : '';
html += `<span class="hwfit-col ${col.cls}${sortable}${active}"${dataAttr}>${col.label}${arrow}</span>`; // Fit column gets a small dot to its left that toggles "show only models
// that fit" — replaces the old Fits On/Off button next to the toolbar.
let label = col.label;
if (col.cls === 'hwfit-fit') {
const _fitOnly = (() => { try { return localStorage.getItem('hwfit_fit_only_v1') === '1'; } catch { return false; } })();
label = `<span class="hwfit-fit-dot${_fitOnly ? ' active' : ''}" title="${_fitOnly ? 'Showing only models that fit. Click to also show too-tight rows.' : 'Click to show only models that fit your hardware.'}" data-fit-dot>●</span>${col.label}`;
// (Budget tag removed — the GPU/RAM/N-GPU suffix next to "Fit" was noise;
// the toggle row already shows which budget is active.)
}
html += `<span class="hwfit-col ${col.cls}${sortable}${active}"${dataAttr}>${label}${arrow}</span>`;
} }
html += '</div>'; html += '</div>';
for (const m of models) { for (const m of models) {
@@ -875,9 +919,31 @@ export function _hwfitRenderList(el, models) {
const dlDot = (_cachedModelIds && (_cachedModelIds.has(m.name) || [..._cachedModelIds].some(id => id === m.name?.split('/').pop()))) ? '<span class="hwfit-dl-dot" title="Downloaded">\u25CF</span>' : ''; const dlDot = (_cachedModelIds && (_cachedModelIds.has(m.name) || [..._cachedModelIds].some(id => id === m.name?.split('/').pop()))) ? '<span class="hwfit-dl-dot" title="Downloaded">\u25CF</span>' : '';
html += `<div class="hwfit-row" data-model="${esc(m.name)}">`; html += `<div class="hwfit-row" data-model="${esc(m.name)}">`;
html += `<span class="hwfit-col hwfit-fit" style="color:${fitColor}">${esc(fitLabel)}</span>`; html += `<span class="hwfit-col hwfit-fit" style="color:${fitColor}">${esc(fitLabel)}</span>`;
html += `<span class="hwfit-col hwfit-name">${modelLogo(m.name)}${esc(m.name?.split('/').pop() || m.name)}${moeBadge}${imgBadge}${dlDot}</span>`; // Append quant to the title when it's not already in the repo name. The
// suffix strips quant-parts the name already contains — e.g. for
// QuantTrio/MiniMax-M2-AWQ + quant=AWQ-4bit we just show "(4bit)", not
// "(AWQ-4bit)". DeepSeek-V4-Flash + FP4-MoE-Mixed keeps the full tag
// (none of those parts are in the repo id).
const _short = m.name?.split('/').pop() || m.name || '';
const _quantTag = (m.quant || '').trim();
const _lowerShort = _short.toLowerCase();
let _quantSuffix = '';
if (_quantTag) {
const _parts = _quantTag.split(/[-_]/).filter(Boolean);
const _remaining = _parts.filter(p => !_lowerShort.includes(p.toLowerCase()));
if (_remaining.length && _remaining.length < _parts.length + 1) { // at least one part is new
let _display = _remaining.join('-');
if (_display.length > 9) _display = _display.slice(0, 9) + '…';
_quantSuffix = ` <span class="hwfit-name-quant" title="${esc(_quantTag)} — full storage format">(${esc(_display)})</span>`;
}
}
html += `<span class="hwfit-col hwfit-name">${modelLogo(m.name)}${esc(_short)}${_quantSuffix}${moeBadge}${imgBadge}${dlDot}</span>`;
html += `<span class="hwfit-col hwfit-c-params">${esc(pcount)}</span>`; html += `<span class="hwfit-col hwfit-c-params">${esc(pcount)}</span>`;
html += `<span class="hwfit-col hwfit-c-quant">${esc(m.quant || '?')}</span>`; // Truncate the Quant cell to 9 chars + ellipsis so long tags like
// "FP4-MoE-Mixed" don't push neighboring columns. Full tag stays in title.
const _qRaw = m.quant || '?';
const _qShort = _qRaw.length > 9 ? _qRaw.slice(0, 9) + '…' : _qRaw;
html += `<span class="hwfit-col hwfit-c-quant" title="${esc(_qRaw)}">${esc(_qShort)}</span>`;
html += `<span class="hwfit-col hwfit-c-vram">${vramLabel}</span>`; html += `<span class="hwfit-col hwfit-c-vram">${vramLabel}</span>`;
html += `<span class="hwfit-col hwfit-c-ctx">${m.is_image_gen ? '\u2014' : ctx}</span>`; html += `<span class="hwfit-col hwfit-c-ctx">${m.is_image_gen ? '\u2014' : ctx}</span>`;
html += `<span class="hwfit-col hwfit-c-speed">${m.is_image_gen ? '\u2014' : tps + ' t/s'}</span>`; html += `<span class="hwfit-col hwfit-c-speed">${m.is_image_gen ? '\u2014' : tps + ' t/s'}</span>`;
@@ -899,7 +965,26 @@ export function _hwfitRenderList(el, models) {
}); });
// Clickable header columns → sort (click again to toggle direction) // Clickable header columns → sort (click again to toggle direction)
el.querySelectorAll('.hwfit-header .hwfit-sortable').forEach(col => { el.querySelectorAll('.hwfit-header .hwfit-sortable').forEach(col => {
col.addEventListener('click', () => { col.addEventListener('click', (e) => {
// The little dot inside the Fit header is its own toggle (fit-only
// filter), don't let it fall through to a sort click.
if (e.target.closest('[data-fit-dot]')) {
const on = !e.target.classList.contains('active');
try { localStorage.setItem('hwfit_fit_only_v1', on ? '1' : '0'); } catch {}
// Un-toggling the fit filter (off → showing too-tight rows again) is
// typically because the user wants to see the LARGE models they can't
// run yet — re-sort by VRAM descending so the biggest surface first.
if (!on) {
const sortSel = document.getElementById('hwfit-sort');
if (sortSel) {
sortSel.value = 'vram';
sortSel.dataset.reverse = '0'; // descending (biggest first)
}
}
_hwfitCache = null;
_hwfitFetch();
return;
}
const sortKey = col.dataset.sort; const sortKey = col.dataset.sort;
if (!sortKey) return; if (!sortKey) return;
const sel = document.getElementById('hwfit-sort'); const sel = document.getElementById('hwfit-sort');
@@ -983,7 +1068,16 @@ export function _expandModelRow(row, modelData) {
if (modelData.is_image_gen) { if (modelData.is_image_gen) {
html += `<div style="font-size:10px;opacity:0.5;margin-top:4px;">${esc((modelData.capabilities || []).join(' \u00B7 ') || '')}${modelData.description ? ' \u2014 ' + esc(modelData.description) : ''}</div>`; html += `<div style="font-size:10px;opacity:0.5;margin-top:4px;">${esc((modelData.capabilities || []).join(' \u00B7 ') || '')}${modelData.description ? ' \u2014 ' + esc(modelData.description) : ''}</div>`;
} else if (_requiresAcceleratorBackend(modelData)) { } else if (_requiresAcceleratorBackend(modelData)) {
html += `<div class="hwfit-panel-note">This is a safetensors GPU-serving format. Use vLLM/SGLang with a visible CUDA/ROCm accelerator, or pick a GGUF download for llama.cpp/Ollama.</div>`; // Only show the "needs CUDA/ROCm" note when the host doesn't already have
// one. With a visible CUDA/ROCm accelerator the note is noise — the user
// can already serve the model and reading the warning on every row makes
// the panel feel like everything's broken.
const _sys = _hwfitCache?.system || {};
const _backend = (_sys.backend || '').toLowerCase();
const _hasGpuAccel = !!_sys.has_gpu && (_backend === 'cuda' || _backend === 'rocm');
if (!_hasGpuAccel) {
html += `<div class="hwfit-panel-note">This is a safetensors GPU-serving format. Use vLLM/SGLang with a visible CUDA/ROCm accelerator, or pick a GGUF download for llama.cpp/Ollama.</div>`;
}
} }
html += `</div>`; html += `</div>`;
@@ -1207,11 +1301,15 @@ export function _hwfitInit() {
ctx.addEventListener('change', () => { ctx.addEventListener('change', () => {
const targetCtx = _ctxValue(); const targetCtx = _ctxValue();
try { localStorage.setItem(_CTX_KEY, String(targetCtx)); } catch {} try { localStorage.setItem(_CTX_KEY, String(targetCtx)); } catch {}
// Ctx drag affects sort mode: a specific ctx target (anything < Max)
// implies "what runs at this context length" — sort by VRAM ascending
// so the cheapest-fitting models surface first. Dragging back to Max
// releases the constraint → go back to the default score ranking.
const sortSel = document.getElementById('hwfit-sort'); const sortSel = document.getElementById('hwfit-sort');
if (sortSel) { if (sortSel) {
if (targetCtx) { if (targetCtx) {
sortSel.value = 'fit'; sortSel.value = 'vram';
sortSel.dataset.reverse = '1'; sortSel.dataset.reverse = '1'; // ascending = smallest VRAM first
} else { } else {
sortSel.value = 'score'; sortSel.value = 'score';
sortSel.dataset.reverse = ''; sortSel.dataset.reverse = '';
+110 -28
View File
@@ -18,6 +18,7 @@ import {
_launchServeTask, _serveAutoFix, _serveAutoRetry, _serveAutoRetryReplace, _serveAutoRetryRemove, _launchServeTask, _serveAutoFix, _serveAutoRetry, _serveAutoRetryReplace, _serveAutoRetryRemove,
_startBackgroundMonitor, _syncFromServer, _startBackgroundMonitor, _syncFromServer,
_retryDownload, _nextAvailablePort, _processQueue, _retryDownload, _nextAvailablePort, _processQueue,
_selfHealStaleTasks,
} from './cookbookRunning.js'; } from './cookbookRunning.js';
import { import {
@@ -641,6 +642,13 @@ async function _fetchDependencies() {
const winBlocked = !isLocal && _isWindows() && _winUnsupported.has(pkg.name); const winBlocked = !isLocal && _isWindows() && _winUnsupported.has(pkg.name);
const note = pkg.status_note ? `<div class="memory-item-meta" style="font-size:10px;opacity:0.65;margin-top:3px;">${esc(pkg.status_note)}</div>` : ''; const note = pkg.status_note ? `<div class="memory-item-meta" style="font-size:10px;opacity:0.65;margin-top:3px;">${esc(pkg.status_note)}</div>` : '';
const updateNote = pkg.installed && pkg.pip_update_available === false && pkg.update_note ? `<div class="memory-item-meta" style="font-size:10px;opacity:0.55;margin-top:3px;">${esc(pkg.update_note)}</div>` : ''; const updateNote = pkg.installed && pkg.pip_update_available === false && pkg.update_note ? `<div class="memory-item-meta" style="font-size:10px;opacity:0.55;margin-top:3px;">${esc(pkg.update_note)}</div>` : '';
// Inline "Rebuild" tag for the llama_cpp row only. Styled as a
// .cookbook-dep-tag so it matches the LLM category tag's pill look,
// and lives to the LEFT of the category tag (clear affordance before
// the row "value").
const _rebuildBtn = (pkg.name === 'llama_cpp')
? `<button type="button" class="cookbook-dep-tag cookbook-dep-rebuild" id="cookbook-rebuild-engine" title="Clear the cached llama.cpp build so the next serve recompiles from source (use after installing a CUDA/ROCm toolkit to turn a CPU-only build into a GPU build).">Rebuild</button>`
: '';
return `<div class="cookbook-dep-row${winBlocked ? ' cookbook-dep-blocked' : ''}" data-pkg-name="${esc(pkg.name)}" data-dep-pip="${esc(pkg.pip || '')}" data-dep-target="${isLocal ? 'local' : 'remote'}" data-dep-kind="${esc(pkg.kind || 'python')}">` return `<div class="cookbook-dep-row${winBlocked ? ' cookbook-dep-blocked' : ''}" data-pkg-name="${esc(pkg.name)}" data-dep-pip="${esc(pkg.pip || '')}" data-dep-target="${isLocal ? 'local' : 'remote'}" data-dep-kind="${esc(pkg.kind || 'python')}">`
+ `<div class="cookbook-dep-info">` + `<div class="cookbook-dep-info">`
+ `<div class="memory-item-title">${esc(pkg.name)}</div>` + `<div class="memory-item-title">${esc(pkg.name)}</div>`
@@ -648,6 +656,7 @@ async function _fetchDependencies() {
+ note + note
+ updateNote + updateNote
+ `</div>` + `</div>`
+ _rebuildBtn
+ `<span class="cookbook-dep-tag cookbook-dep-cat">${esc(pkg.category)}</span>` + `<span class="cookbook-dep-tag cookbook-dep-cat">${esc(pkg.category)}</span>`
+ _statusTag(pkg, isLocal, isSystemDep, winBlocked) + _statusTag(pkg, isLocal, isSystemDep, winBlocked)
+ `</div>`; + `</div>`;
@@ -1116,6 +1125,7 @@ function _wireTabEvents(body) {
document.getElementById('serve-bulk-cancel')?.addEventListener('click', () => { document.getElementById('serve-bulk-cancel')?.addEventListener('click', () => {
selectBtn.classList.remove('active'); selectBtn.classList.remove('active');
selectBtn.textContent = 'Select'; // reset label so the button doesn't stay reading "Cancel" after exit
bulkBar.classList.add('hidden'); bulkBar.classList.add('hidden');
document.querySelectorAll('.serve-select-cb').forEach(dot => { dot.style.display = 'none'; dot.classList.remove('selected'); }); document.querySelectorAll('.serve-select-cb').forEach(dot => { dot.style.display = 'none'; dot.classList.remove('selected'); });
}); });
@@ -1134,6 +1144,7 @@ function _wireTabEvents(body) {
if (item) await _deleteCachedModel(repo, item, true); if (item) await _deleteCachedModel(repo, item, true);
} }
selectBtn.classList.remove('active'); selectBtn.classList.remove('active');
selectBtn.textContent = 'Select'; // same reset as bulk-cancel
bulkBar.classList.add('hidden'); bulkBar.classList.add('hidden');
document.querySelectorAll('.serve-select-cb').forEach(dot => { dot.style.display = 'none'; dot.classList.remove('selected'); }); document.querySelectorAll('.serve-select-cb').forEach(dot => { dot.style.display = 'none'; dot.classList.remove('selected'); });
}); });
@@ -1224,6 +1235,24 @@ function _wireTabEvents(body) {
} }
// Latest HF models that fit — collapsible card list // Latest HF models that fit — collapsible card list
// Foldable Download admin-card — h2 "Download" doubles as the chevron
// toggle; collapses the entire card body (description + input + HF list).
// State persisted to localStorage so the fold sticks across reloads.
const dlFold = document.getElementById('cookbook-dl-tab-fold');
const dlFoldBody = document.getElementById('cookbook-dl-tab-fold-body');
const dlFoldChevron = document.getElementById('cookbook-dl-tab-chevron');
if (dlFold && dlFoldBody && dlFoldChevron) {
dlFold.addEventListener('click', () => {
const folded = dlFoldBody.style.display === 'none';
dlFoldBody.style.display = folded ? '' : 'none';
dlFoldChevron.textContent = folded ? '▾' : '▸';
// Toggle is-folded class on the h2 so the line under it only shows when
// the section is collapsed (the body's content normally provides
// separation; with no body visible, the line gives the h2 definition).
dlFold.classList.toggle('is-folded', !folded);
try { localStorage.setItem('cookbook_dl_tab_folded_v1', folded ? '0' : '1'); } catch {}
});
}
const hfToggle = document.getElementById('cookbook-hf-latest-toggle'); const hfToggle = document.getElementById('cookbook-hf-latest-toggle');
const hfArrow = document.getElementById('cookbook-hf-latest-arrow'); const hfArrow = document.getElementById('cookbook-hf-latest-arrow');
const hfList = document.getElementById('cookbook-hf-latest-list'); const hfList = document.getElementById('cookbook-hf-latest-list');
@@ -1389,9 +1418,32 @@ function _wireTabEvents(body) {
// HF token — save on change // HF token — save on change
const hfInput = document.getElementById('hwfit-hftoken'); const hfInput = document.getElementById('hwfit-hftoken');
if (hfInput) { if (hfInput) {
hfInput.addEventListener('change', () => { hfInput.addEventListener('change', async () => {
_envState.hfToken = hfInput.value.trim(); const val = hfInput.value.trim();
_persistEnvState(); _envState.hfToken = val;
try { await _persistEnvState(); } catch {}
if (val) {
_envState.hfTokenConfigured = true;
const masked = val.length > 6 ? val.slice(0, 3) + '…' + val.slice(-3) : '••••';
_envState.hfTokenMasked = masked;
hfInput.placeholder = `Stored (${masked}) - enter a new token to replace`;
hfInput.value = '';
let check = hfInput.parentNode.querySelector('.hwfit-hf-check');
if (!check) {
check = document.createElement('span');
check.className = 'hwfit-hf-check';
check.title = 'Token stored';
check.textContent = '✓';
check.style.cssText = 'font-weight:800;color:var(--green,#50fa7b);font-size:15px;line-height:1;flex-shrink:0;position:relative;top:2px;';
hfInput.parentNode.insertBefore(check, hfInput);
}
const flash = document.createElement('span');
flash.textContent = 'Saved';
flash.style.cssText = 'margin-left:8px;font-size:11px;color:var(--green,#50fa7b);opacity:0;transition:opacity 0.18s;flex-shrink:0;position:relative;top:1px;';
hfInput.parentNode.appendChild(flash);
requestAnimationFrame(() => { flash.style.opacity = '1'; });
setTimeout(() => { flash.style.opacity = '0'; setTimeout(() => flash.remove(), 220); }, 1400);
}
}); });
} }
} }
@@ -1417,7 +1469,7 @@ export function _serverEntryHtml(s, i, defaultServer, forceRemote, isNew) {
html += `<span class="cookbook-server-title" style="display:flex;align-items:center;gap:6px;width:100%;font-size:13px;font-weight:600;margin-bottom:4px;">`; html += `<span class="cookbook-server-title" style="display:flex;align-items:center;gap:6px;width:100%;font-size:13px;font-weight:600;margin-bottom:4px;">`;
html += `${esc(_srvTitle)}`; html += `${esc(_srvTitle)}`;
html += _pIco ? `<span class="cookbook-srv-platform" title="${esc(s.platform || '')}" style="display:inline-flex;align-items:center;opacity:0.55;">${_pIco}</span>` : ''; html += _pIco ? `<span class="cookbook-srv-platform" title="${esc(s.platform || '')}" style="display:inline-flex;align-items:center;opacity:0.55;">${_pIco}</span>` : '';
html += `<span class="cookbook-srv-test-msg" style="font-size:10px;font-weight:400;opacity:0.55;max-width:160px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;position:relative;top:2px;"></span>`; html += `<span class="cookbook-srv-test-msg" style="font-size:10px;font-weight:400;opacity:0.55;max-width:160px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;position:relative;top:1px;"></span>`;
if (isNew) { if (isNew) {
// New server: Cancel (discard) sits top-right; the default toggle only makes // New server: Cancel (discard) sits top-right; the default toggle only makes
// sense once the server is saved. // sense once the server is saved.
@@ -1491,12 +1543,15 @@ function _renderRecipes() {
// Search group // Search group
html += '<div class="cookbook-group" data-backend-group="Search" style="flex:0 0 auto;">'; html += '<div class="cookbook-group" data-backend-group="Search" style="flex:0 0 auto;">';
html += '<div class="admin-card" style="display:flex;flex-direction:column;overflow:hidden;">'; html += '<div class="admin-card" style="display:flex;flex-direction:column;overflow:hidden;">';
html += '<button type="button" id="cookbook-download-card-toggle" style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;width:100%;background:transparent;border:0;padding:0;color:inherit;text-align:left;cursor:pointer;">'; // Foldable Download admin-card: clicking the h2 header collapses the
html += '<h2 style="margin:0;padding:0;line-height:1;">Download</h2>'; // entire card body (description + download input + HF latest section).
html += '<span id="cookbook-download-card-arrow" style="margin-left:auto;display:inline-block;transition:transform 0.15s;font-size:13px;line-height:1;">\u25B8</span>'; // State persisted to localStorage so the fold survives reloads.
html += '</button>'; const _dlTabFolded = (() => { try { return localStorage.getItem('cookbook_dl_tab_folded_v1') === '1'; } catch { return false; } })();
html += '<div id="cookbook-download-card-body" style="display:none;">'; html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;">';
html += '<p class="memory-desc doclib-desc" style="margin-top:6px;">Download directly from Scan, or paste a HuggingFace model link.</p>'; html += `<h2 id="cookbook-dl-tab-fold" class="${_dlTabFolded ? 'is-folded' : ''}" style="margin:0;padding:0;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:space-between;user-select:none;flex:1;">Download<span id="cookbook-dl-tab-chevron" style="display:inline-block;transition:transform 0.15s;font-size:1.1em;margin-left:8px;opacity:0.85;">${_dlTabFolded ? '▸' : '▾'}</span></h2>`;
html += '</div>';
html += `<div id="cookbook-dl-tab-fold-body" style="${_dlTabFolded ? 'display:none;' : ''}">`;
html += '<p class="memory-desc doclib-desc" style="margin-top:6px;">Download from <a href="https://huggingface.co/models" target="_blank" rel="noopener" style="color:var(--accent,var(--red));text-decoration:none;"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:1px;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>HuggingFace</a> by pasting model link, or download directly in the Scan section below.</p>';
html += '<div class="hwfit-container" id="hwfit-container">'; html += '<div class="hwfit-container" id="hwfit-container">';
// Section 1: Settings // Section 1: Settings
@@ -1551,6 +1606,7 @@ function _renderRecipes() {
html += `</div>`; html += `</div>`;
html += `<div id="cookbook-hf-latest-list" style="display:none;margin-top:4px;max-height:320px;overflow-y:auto;flex-direction:column;gap:4px;"></div>`; html += `<div id="cookbook-hf-latest-list" style="display:none;margin-top:4px;max-height:320px;overflow-y:auto;flex-direction:column;gap:4px;"></div>`;
html += `</div>`; html += `</div>`;
html += `</div>`; // /#cookbook-dl-tab-fold-body (whole Download card body)
// Search section // Search section
html += '</div></div></div></div>'; html += '</div></div></div></div>';
@@ -1562,33 +1618,43 @@ function _renderRecipes() {
html += '<p class="memory-desc doclib-desc" style="margin-top:6px;">Scans your hardware for what models you can run. Hardware is cached; hit the scan button to re-probe after changing GPUs.</p>'; html += '<p class="memory-desc doclib-desc" style="margin-top:6px;">Scans your hardware for what models you can run. Hardware is cached; hit the scan button to re-probe after changing GPUs.</p>';
html += '<div class="hwfit-toolbar" style="margin-top:9px;">'; html += '<div class="hwfit-toolbar" style="margin-top:9px;">';
html += '<select class="cookbook-field-input hwfit-usecase" id="hwfit-usecase" style="height:28px;">'; html += '<select class="cookbook-field-input hwfit-usecase" id="hwfit-usecase" style="height:28px;">';
html += '<option value="">Type</option><option value="general">General</option><option value="coding">Coding</option>'; html += '<option value="general" selected>Standard</option><option value="coding">Coding</option>';
html += '<option value="reasoning">Reasoning</option><option value="chat">Chat</option>'; html += '<option value="reasoning">Reasoning</option><option value="chat">Chat</option>';
// Image tab removed — text→image gen is gone from this build (only inpaint // Image tab removed — text→image gen is gone from this build (only inpaint
// remains, which uses its own settings panel). Vision (multimodal) stays. // remains, which uses its own settings panel). Vision (multimodal) stays.
html += '<option value="multimodal">Vision</option></select>'; html += '<option value="multimodal">Vision</option></select>';
html += '<input type="text" class="cookbook-field-input hwfit-search" id="hwfit-search" placeholder="Search models..." style="flex:1;" />'; // Engine sits next to the type filter so the "what category / which serving
// Quant (Q4/Q8/…) lives next to the search now. // path" filters live together; Quant + Context are storage-format and budget
html += '<select class="cookbook-field-input hwfit-quant" id="hwfit-quant" style="height:28px;">'; // levers, grouped to the right.
html += '<option value="Q4_K_M">Q4</option><option value="Q8_0">Q8</option>'; html += '<span class="hwfit-engine-wrap">';
html += '<option value="Q6_K">Q6</option><option value="Q5_K_M">Q5</option>';
html += '<option value="Q3_K_M">Q3</option><option value="Q2_K">Q2</option>';
html += '<option value="AWQ-4bit">AWQ</option><option value="FP8">FP8</option><option value="FP4">FP4</option><option value="NVFP4">NVFP4</option>';
html += '<option value="">Native</option></select>';
// Engine filter: show only models whose serve engine matches. "llama.cpp"
// (GGUF) runs everywhere incl. consumer AMD/Apple; vLLM/SGLang are CUDA /
// datacenter-ROCm. Filtering is client-side via _detectBackend() in the
// hwfit renderer, so it composes with the quant/type/search filters.
html += '<select class="cookbook-field-input hwfit-engine" id="hwfit-engine" style="height:28px;" title="Filter by serving engine">'; html += '<select class="cookbook-field-input hwfit-engine" id="hwfit-engine" style="height:28px;" title="Filter by serving engine">';
html += '<option value="">Engine</option>'; html += '<option value="">Engine</option>';
html += '<option value="llamacpp">llama.cpp</option>'; html += '<option value="llamacpp">llama.cpp</option>';
html += '<option value="vllm">vLLM</option>'; html += '<option value="vllm">vLLM</option>';
html += '<option value="sglang">SGLang</option>'; html += '<option value="sglang">SGLang</option>';
html += '</select>'; html += '</select>';
html += '<span class="hwfit-help-chip" title="Higher numbers usually mean better quality, but they need more memory. Lower numbers fit on more hardware.">?</span>'; html += '<span class="hwfit-help-chip hwfit-help-chip-inline hwfit-engine-help" title="Rule of thumb: GGUF on single GPU / CPU+RAM → llama.cpp (or Ollama). Safetensors on multi-GPU NVIDIA → vLLM. SGLang is a vLLM-class alternative, sometimes faster on big-MoE / long-context.">?</span>';
html += '</span>';
// Quant (Q4/Q8/…). Default is "All" so the list shows the best-scoring
// quant for every model instead of silently filtering to Q4.
html += '<span class="hwfit-quant-wrap">';
html += '<select class="cookbook-field-input hwfit-quant" id="hwfit-quant" style="height:28px;">';
html += '<option value="" selected>Quant: All</option>';
html += '<option value="Q4_K_M">Q4</option><option value="Q8_0">Q8</option>';
html += '<option value="Q6_K">Q6</option><option value="Q5_K_M">Q5</option>';
html += '<option value="Q3_K_M">Q3</option><option value="Q2_K">Q2</option>';
html += '<option value="AWQ-4bit">AWQ</option><option value="FP8">FP8</option><option value="FP4">FP4</option><option value="NVFP4">NVFP4</option></select>';
html += '<span class="hwfit-help-chip hwfit-help-chip-inline hwfit-quant-help" title="Lower quant tiers (Q2/Q3/Q4 / AWQ-4bit) are smaller, faster, and cheaper to run, at some quality loss. Higher tiers (Q8 / FP8 / FP16 / BF16) preserve more quality but need more VRAM. “All” shows the best-scoring quant per model — pick a specific one to filter.">?</span>';
html += '</span>';
// Ctx slider — lets you target a context length for fit estimates; the
// hwfit ranking uses _ctxValue() to factor that into VRAM math, so
// dragging this re-sorts the list toward models that fit your chosen ctx.
html += '<label class="hwfit-ctx-control" title="Context length for fit estimates. Lower it to find more models that could fit your hardware.">'; html += '<label class="hwfit-ctx-control" title="Context length for fit estimates. Lower it to find more models that could fit your hardware.">';
html += '<span>Ctx</span><span class="hwfit-help-chip hwfit-help-chip-inline" title="Context length. Lower it to find more models that could fit your hardware; raise it when you need longer chats or documents.">?</span><input type="range" id="hwfit-context" min="0" max="5" step="1" value="3" />'; html += '<span>Context</span><span class="hwfit-help-chip hwfit-help-chip-inline" title="Context length. Lower it to find more models that could fit your hardware; raise it when you need longer chats or documents.">?</span><input type="range" id="hwfit-context" min="0" max="5" step="1" value="3" />';
html += '<output id="hwfit-context-label">50k</output></label>'; html += '<output id="hwfit-context-label">50k</output></label>';
// Search lives at the far right of the toolbar so the controls (Type/Quant/
// Engine/Context) read as a row of compact filters followed by free-text.
html += '<input type="text" class="cookbook-field-input hwfit-search" id="hwfit-search" placeholder="Search models..." style="flex:1;" />';
html += '</div>'; html += '</div>';
html += '<div class="hwfit-toolbar" style="margin-top:7px;">'; html += '<div class="hwfit-toolbar" style="margin-top:7px;">';
html += '<select class="cookbook-field-input hwfit-server-select" id="hwfit-server-select" style="height:28px;min-width:88px;position:relative;top:0px;">'; html += '<select class="cookbook-field-input hwfit-server-select" id="hwfit-server-select" style="height:28px;min-width:88px;position:relative;top:0px;">';
@@ -1597,7 +1663,7 @@ function _renderRecipes() {
html += '<div class="hwfit-gpu-toggles" id="hwfit-gpu-toggles"></div>'; html += '<div class="hwfit-gpu-toggles" id="hwfit-gpu-toggles"></div>';
// Scan/refresh button (icon-only) where the quant dropdown used to sit. // Scan/refresh button (icon-only) where the quant dropdown used to sit.
html += '<button type="button" class="hwfit-gpu-btn" id="hwfit-rescan" title="Re-scan hardware" style="flex-shrink:0;position:relative;top:-3px;left:-1px;">↻ RESCAN</button>'; html += '<button type="button" class="hwfit-gpu-btn" id="hwfit-rescan" title="Re-scan hardware" style="flex-shrink:0;position:relative;top:-3px;left:-1px;">↻ RESCAN</button>';
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-manual-btn" id="hwfit-hw-manual-btn" title="Set hardware manually" style="flex-shrink:0;position:relative;top:-3px;left:-1px;">EDIT</button>'; html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-manual-btn" id="hwfit-hw-manual-btn" title="Set hardware manually" style="flex-shrink:0;position:relative;top:-3px;left:-1px;display:inline-flex;align-items:center;gap:3px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>EDIT</button>';
// Sort state — the clickable column headers read/write this (pewds' original // Sort state — the clickable column headers read/write this (pewds' original
// sort paradigm). Newest is reachable by clicking the Model column header. // sort paradigm). Newest is reachable by clicking the Model column header.
html += '<select class="cookbook-field-input hwfit-sort" id="hwfit-sort" style="display:none">'; html += '<select class="cookbook-field-input hwfit-sort" id="hwfit-sort" style="display:none">';
@@ -1617,6 +1683,16 @@ function _renderRecipes() {
html += '</div>'; html += '</div>';
html += '<div id="hwfit-hw-row" style="display:none;align-items:center;gap:4px;margin-top:3px;padding-top:2px;"><span style="font-size:10px;padding:2px 8px;border-radius:10px;background:color-mix(in srgb, var(--fg) 8%, transparent);color:var(--fg);opacity:0.7;white-space:nowrap;flex-shrink:0;position:relative;top:-1px;">Detected hardware</span><div class="hwfit-hw" id="hwfit-hw" style="flex:1;"></div></div>'; html += '<div id="hwfit-hw-row" style="display:none;align-items:center;gap:4px;margin-top:3px;padding-top:2px;"><span style="font-size:10px;padding:2px 8px;border-radius:10px;background:color-mix(in srgb, var(--fg) 8%, transparent);color:var(--fg);opacity:0.7;white-space:nowrap;flex-shrink:0;position:relative;top:-1px;">Detected hardware</span><div class="hwfit-hw" id="hwfit-hw" style="flex:1;"></div></div>';
html += '<div class="hwfit-list" id="hwfit-list"></div>'; html += '<div class="hwfit-list" id="hwfit-list"></div>';
// Footer: link to the public discussion where users can request additions
// to the curated model list. Sits below the list so it reads as a callout
// after browsing, not a header.
html += '<div class="hwfit-list-footer" style="margin-top:8px;padding-top:6px;border-top:1px solid color-mix(in srgb, var(--border) 50%, transparent);font-size:9.5px;opacity:0.65;text-align:right;">'
+ 'Don\'t see a model? '
+ '<a href="https://github.com/pewdiepie-archdaemon/odysseus/discussions/1962" target="_blank" rel="noopener" style="color:var(--accent,var(--red));text-decoration:none;display:inline-flex;align-items:center;gap:4px;vertical-align:middle;">'
+ 'Request it →'
+ '<svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style="flex-shrink:0;"><path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>'
+ '</a>'
+ '</div>';
html += '</div></div>'; html += '</div></div>';
@@ -1661,7 +1737,8 @@ function _renderRecipes() {
html += '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">'; html += '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">';
html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">'; html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">';
html += '<h2 style="margin:0;padding:0;line-height:1;">Dependencies</h2>'; html += '<h2 style="margin:0;padding:0;line-height:1;">Dependencies</h2>';
html += '<button class="cookbook-field-input" id="cookbook-rebuild-engine" title="Clear the cached llama.cpp build so the next serve recompiles from source (use after installing a CUDA/ROCm toolkit to turn a CPU-only build into a GPU build)." style="height:24px;font-size:10px;padding:0 8px;cursor:pointer;width:auto;">Rebuild llama.cpp</button>'; // Rebuild llama.cpp button moved into the llama_cpp dep row (see _depRow);
// having it in the title polluted the section header.
html += '<span style="font-size:10px;opacity:0.5;margin-left:auto;">Server</span>'; html += '<span style="font-size:10px;opacity:0.5;margin-left:auto;">Server</span>';
html += '<select class="cookbook-field-input" id="hwfit-deps-server" style="height:28px;min-width:70px;">'; html += '<select class="cookbook-field-input" id="hwfit-deps-server" style="height:28px;min-width:70px;">';
html += _buildServerOpts(false); html += _buildServerOpts(false);
@@ -1700,7 +1777,7 @@ function _renderRecipes() {
// ── Servers block ─────────────────────────────────────────────────── // ── Servers block ───────────────────────────────────────────────────
html += '<div class="admin-card" style="flex:0 0 auto;display:flex;flex-direction:column;">'; html += '<div class="admin-card" style="flex:0 0 auto;display:flex;flex-direction:column;">';
html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;margin-top:-8px;">'; html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;margin-top:-4px;">';
html += '<h2 style="margin:0;padding:0;line-height:1;">Servers</h2>'; html += '<h2 style="margin:0;padding:0;line-height:1;">Servers</h2>';
// Reuse the calendar +New pill: spinning plus, label fades in idea uses // Reuse the calendar +New pill: spinning plus, label fades in idea uses
// the same `.cal-add-btn-text` rules, so styling stays consistent. // the same `.cal-add-btn-text` rules, so styling stays consistent.
@@ -1847,6 +1924,11 @@ export async function open(opts) {
_rendered = true; _rendered = true;
_clearCookbookNotif(); _clearCookbookNotif();
_renderRunningTab(); _renderRunningTab();
// Self-heal: revive any download tasks whose tmux session is still alive
// but were persisted as done/error (covers the "restarted server while a
// big multi-shard download was in flight" case — the task survived in
// tmux, the cookbook just lost track of it).
try { _selfHealStaleTasks({ oneShot: true }); } catch {}
if (_content) { if (_content) {
// Put the panel in its entering state before it becomes visible. On // Put the panel in its entering state before it becomes visible. On
// mobile, showing first and adding the class a frame later can paint the // mobile, showing first and adding the class a frame later can paint the
+36
View File
@@ -535,6 +535,42 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
uiModule.showToast(`${shortName} is already ${duplicate.status === 'queued' ? 'queued' : 'downloading'}`); uiModule.showToast(`${shortName} is already ${duplicate.status === 'queued' ? 'queued' : 'downloading'}`);
return; return;
} }
// Also catch zombie "done" tasks — the cookbook may have lost track of a
// download (server restart, stale state) while its tmux session is still
// alive on the host. Probe it; if alive, flip back to running + treat as
// duplicate so we don't kick off a second concurrent download writing to
// the same target dir.
const zombieCandidate = tasks.find(t => sameDownload(t)
&& ['done', 'error', 'crashed', 'stopped'].includes(t.status)
&& t.sessionId && !String(t.sessionId).startsWith('queue-'));
if (zombieCandidate) {
try {
const _zh = zombieCandidate.remoteHost || '';
const _zPort = (_envState.servers || []).find(s => s.host === _zh)?.port;
const _sshPf = _zh ? `ssh ${_zPort && _zPort !== '22' ? `-p ${_zPort} ` : ''}${_zh} '` : '';
const _sshSf = _zh ? `'` : '';
const _probeCmd = `${_sshPf}tmux has-session -t ${zombieCandidate.sessionId} 2>/dev/null${_sshSf}`;
const _r = await fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: _probeCmd, timeout: 5 }),
});
const _d = await _r.json();
if (_d.exit_code === 0) {
// tmux still alive → not actually done. Revive + tell the user.
const _fresh = _loadTasks();
const _ft = _fresh.find(t => t.sessionId === zombieCandidate.sessionId);
if (_ft) {
_ft.status = 'running';
_ft._selfHealed = true;
_saveTasks(_fresh);
}
_renderRunningTab();
uiModule.showToast(`${shortName} is still downloading (was marked finished after a restart — revived)`);
return;
}
} catch { /* probe failed — fall through and let the user launch */ }
}
const activeOnHost = tasks.find(t => t.type === 'download' && (t.status === 'running' || t.status === 'queued') && (t.remoteHost || 'local') === targetHost); const activeOnHost = tasks.find(t => t.type === 'download' && (t.status === 'running' || t.status === 'queued') && (t.remoteHost || 'local') === targetHost);
if (activeOnHost) { if (activeOnHost) {
+190 -5
View File
@@ -35,13 +35,34 @@ function _taskBadge(task) {
return { text: _statusLabel(task.status, task.type), cls: 'cookbook-task-' + task.status }; return { text: _statusLabel(task.status, task.type), cls: 'cookbook-task-' + task.status };
} }
// A download task whose tmux output still shows an active per-shard line
// (e.g. "model-00012-of-00082.safetensors: 56%|") is NOT actually finished —
// the cookbook just lost track. The clear pill becomes a "reconnect" affordance
// in that case (click → revive the row + reattach the poll loop).
function _downloadOutputLooksActive(task) {
if (!task || task.type !== 'download') return false;
const out = task.output || '';
if (!out) return false;
if (out.includes('DOWNLOAD_OK') || out.includes('DOWNLOAD_FAILED')) return false;
// An active shard line: filename + a colon + a percentage that isn't 100%.
// We catch any in-flight shard or "Downloading 'X' to ..." line (no %).
return /model-\d+-of-\d+\.[a-z]+:\s+(?!100%)\d+%/i.test(out)
|| /Downloading\s+'[^']+'\s+to\s+'[^']*\.incomplete'/i.test(out);
}
function _canClearTask(task) { function _canClearTask(task) {
if (!task || task.status === 'running') return false; if (!task || task.status === 'running') return false;
if (task.type === 'serve' && (task.status === 'ready' || task._serveReady)) return false; if (task.type === 'serve' && (task.status === 'ready' || task._serveReady)) return false;
// If the tmux output still shows an in-flight download, the task isn't
// actually finished — hide the clear/check pill so it doesn't show on a
// task that's still doing work. (The next render will reflect this and
// ideally the self-heal flips status back to running.)
if (_downloadOutputLooksActive(task)) return false;
return ['done', 'stopped', 'error', 'crashed', 'failed'].includes(task.status); return ['done', 'stopped', 'error', 'crashed', 'failed'].includes(task.status);
} }
function _clearPillLabel(task) { function _clearPillLabel(task) {
if (_downloadOutputLooksActive(task)) return 'reconnect';
return 'clear'; return 'clear';
} }
@@ -1537,7 +1558,16 @@ export function _renderRunningTab() {
const tasks = _loadTasks(); const tasks = _loadTasks();
const hasContent = tasks.length > 0; const hasContent = tasks.length > 0;
const activeCount = tasks.filter(t => t.status === 'running' || t.status === 'queued').length; // Count anything that's really active: explicit 'running'/'queued' status,
// OR a download whose tmux output is still showing live shard progress.
// Without the output check, a task whose status got stuck at 'done' /
// 'crashed' (before auto-reconnect catches it) would read as "Running 0"
// even when the model is actively downloading on the host.
const activeCount = tasks.filter(t =>
t.status === 'running'
|| t.status === 'queued'
|| _downloadOutputLooksActive(t)
).length;
const activeCountHtml = activeCount ? ` <span class="cookbook-tab-count">${activeCount}</span>` : ''; const activeCountHtml = activeCount ? ` <span class="cookbook-tab-count">${activeCount}</span>` : '';
let tabBar = body.querySelector('.cookbook-tabs'); let tabBar = body.querySelector('.cookbook-tabs');
@@ -1805,7 +1835,7 @@ export function _renderRunningTab() {
<span class="cookbook-task-status ${_bdg.cls}"${_bdgTitle}>${esc(_bdg.text)}</span> <span class="cookbook-task-status ${_bdg.cls}"${_bdgTitle}>${esc(_bdg.text)}</span>
<button class="cookbook-task-menu-btn" title="Actions">&#8942;</button> <button class="cookbook-task-menu-btn" title="Actions">&#8942;</button>
</div> </div>
<div class="cookbook-task-sub"><span class="cookbook-task-session">${esc(task.sessionId)}</span><span class="cookbook-task-uptime" style="display:${((task.type === 'serve' || task.type === 'download') && task.status === 'running') ? '' : 'none'}"></span></div> <div class="cookbook-task-sub"><span class="cookbook-task-session">${esc(task.sessionId)}</span><span class="cookbook-task-uptime" style="display:${((task.type === 'serve' || task.type === 'download') && task.status === 'running') ? '' : 'none'}"></span>${(task.type === 'download') ? `<span class="cookbook-task-dldir" title="Download destination" style="font-size:9px;color:var(--fg-muted);font-family:'Fira Code',monospace;opacity:0.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:40ch;">Dir: ${esc(task.payload?.local_dir || '~/.cache/huggingface/hub')}</span>` : ''}</div>
<div class="cookbook-output-wrap cookbook-task-collapsible${_mobileCollapseDefault ? ' cookbook-task-collapsed' : ''}"><pre class="cookbook-output-pre">${esc(task.output || '')}</pre><button type="button" class="copy-code cookbook-output-copy"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div> <div class="cookbook-output-wrap cookbook-task-collapsible${_mobileCollapseDefault ? ' cookbook-task-collapsed' : ''}"><pre class="cookbook-output-pre">${esc(task.output || '')}</pre><button type="button" class="copy-code cookbook-output-copy"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div>
`; `;
@@ -1824,9 +1854,31 @@ export function _renderRunningTab() {
const h = Math.floor(secs / 3600); const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60); const m = Math.floor((secs % 3600) / 60);
const s = secs % 60; const s = secs % 60;
_uptimeEl.textContent = h > 0 const _timer = h > 0
? `${_prefix}: ${h}h ${String(m).padStart(2,'0')}m` ? `${_prefix}: ${h}h ${String(m).padStart(2,'0')}m`
: `${_prefix}: ${m}m ${String(s).padStart(2,'0')}s`; : `${_prefix}: ${m}m ${String(s).padStart(2,'0')}s`;
// ETA — only for downloads, only when we have a meaningful overall %.
// Reads the badge text (which already shows the true overall % we
// compute in the live-polling block) and back-derives a remaining-time
// estimate from elapsed/done. Hidden until pct >= 3% so the early-job
// wild estimates don't show.
let _eta = '';
if (task.type === 'download') {
const _badge = el.querySelector('.cookbook-task-status');
const _m = _badge && /^(\d+)%/.exec(_badge.textContent || '');
const _pct = _m ? parseInt(_m[1], 10) : 0;
if (_pct >= 3 && _pct < 100 && secs > 5) {
const _totalSec = Math.round(secs * (100 / _pct));
const _remain = Math.max(0, _totalSec - secs);
const _eh = Math.floor(_remain / 3600);
const _em = Math.floor((_remain % 3600) / 60);
const _es = _remain % 60;
_eta = _eh > 0
? ` · ETA ${_eh}h ${String(_em).padStart(2,'0')}m`
: (_em > 0 ? ` · ETA ${_em}m ${String(_es).padStart(2,'0')}s` : ` · ETA ${_es}s`);
}
}
_uptimeEl.textContent = _timer + _eta;
}, 1000); }, 1000);
} }
@@ -1874,6 +1926,39 @@ export function _renderRunningTab() {
if (_clearChk) { if (_clearChk) {
_clearChk.addEventListener('click', (e) => { _clearChk.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
// If the output still shows an active shard line, the task isn't
// actually finished — clicking is "reconnect" (flip back to running
// + let _reconnectTask reattach to the live tmux session), not
// "clear". The pill label already reflects this via _clearPillLabel.
if (_downloadOutputLooksActive(task)) {
const _fresh = _loadTasks();
const _ft = _fresh.find(t => t.sessionId === task.sessionId);
if (_ft) {
_ft.status = 'running';
_ft._selfHealed = true;
_saveTasks(_fresh);
}
// Visually flip without waiting for a full re-render — same path the
// self-heal uses on cookbook open.
const _chk = el.querySelector('.cookbook-task-check');
if (_chk) _chk.style.display = 'none';
const _wave = el.querySelector('.cookbook-task-wave');
if (_wave) _wave.style.display = '';
const _up = el.querySelector('.cookbook-task-uptime');
if (_up) _up.style.display = '';
el.dataset.status = 'running';
_renderRunningTab();
return;
}
// Otherwise: real clear. Kill the tmux session as belt-and-suspenders,
// then animate out + remove the row.
try {
fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: _tmuxCmd(task, `kill-session -t ${task.sessionId}`) }),
}).catch(() => {});
} catch {}
_animateOutThenRemove(el, task.sessionId); _animateOutThenRemove(el, task.sessionId);
}); });
} }
@@ -2517,12 +2602,37 @@ async function _reconnectTask(el, task) {
break; break;
} }
// When the snapshot includes a shard-of-N marker (e.g.
// "model-00006-of-00082.safetensors"), TRUE overall progress is
// ((shard-1) + currentShardFraction) / totalShards. Before, _dlAgg
// (hf_transfer's per-current-shard aggregate, e.g. 53% of shard 6)
// was treated as overall and the row read "53%" while only 5 of
// 82 shards were actually done.
const _shardPat = [...snapshot.matchAll(/model-(\d+)-of-(\d+)\.(?:safetensors|bin)/g)];
const _lastShard = _shardPat.length ? _shardPat[_shardPat.length - 1] : null;
const _curShardNum = _lastShard ? parseInt(_lastShard[1], 10) : null;
const _totalShards = _lastShard ? parseInt(_lastShard[2], 10) : null;
const _useShardAgg = _curShardNum && _totalShards && _totalShards > 1;
// HF's own "Fetching N files: X%" aggregate counts ALL files, // HF's own "Fetching N files: X%" aggregate counts ALL files,
// including ones already finished in a previous session (resume) — // including ones already finished in a previous session (resume) —
// so on a resumed download it reflects the true overall progress, // so on a resumed download it reflects the true overall progress,
// whereas completed/totalFiles only see this session's files (→ 0%). // whereas completed/totalFiles only see this session's files (→ 0%).
// Take the higher of the two so resume doesn't read as 0%. // Take the higher of the two so resume doesn't read as 0%.
if (_dlAgg != null) { if (_useShardAgg) {
// Multi-shard download: compute TRUE overall as completed shards
// plus the current shard's fraction. _dlAgg / lastPct represent
// *this shard's* progress, not the whole download.
const curShardFrac = (_dlAgg != null)
? _dlAgg / 100
: (lastPct ? parseInt(lastPct, 10) / 100 : 0);
let overallPct = Math.round((((_curShardNum - 1) + curShardFrac) / _totalShards) * 100);
if (_fetchPct != null) overallPct = Math.max(overallPct, _fetchPct);
let text = `${overallPct}%`;
if (lastSpeed) text += ` · ${lastSpeed}`;
badge.textContent = text;
badge.className = 'cookbook-task-status cookbook-task-running';
} else if (_dlAgg != null) {
// Real aggregate byte progress — most accurate; take the max of all signals. // Real aggregate byte progress — most accurate; take the max of all signals.
let pct = _dlAgg; let pct = _dlAgg;
if (_fetchPct != null) pct = Math.max(pct, _fetchPct); if (_fetchPct != null) pct = Math.max(pct, _fetchPct);
@@ -2927,9 +3037,84 @@ function _refreshServerDots() {
_syncSettingsServerDots(byKey); _syncSettingsServerDots(byKey);
} }
// Self-heal: scan persisted download tasks marked done/error/crashed and
// check whether their tmux session is still alive on the host. If yes —
// the task isn't actually finished, the cookbook just lost the in-flight
// status during restart — flip status back to 'running' so _reconnectTask
// picks it up. The one-shot guard is enforced by callers (open path) or
// time-throttled inside (background-monitor path).
let _selfHealRan = false;
let _selfHealLastTs = 0;
export async function _selfHealStaleTasks(opts = {}) {
// Open-path call: one-shot per page load.
if (opts.oneShot) {
if (_selfHealRan) return;
_selfHealRan = true;
} else {
// Background-monitor call: throttle to once every 8s (the bg monitor
// itself fires every 10s, so this almost always fires too, but the
// guard keeps a fast manual call from doubling up).
const now = Date.now();
if (now - _selfHealLastTs < 8000) return;
_selfHealLastTs = now;
}
const tasks = _loadTasks();
const candidates = tasks.filter(t =>
t.type === 'download'
&& ['done', 'error', 'crashed', 'stopped'].includes(t.status)
&& t.sessionId
&& !String(t.sessionId).startsWith('queue-')
);
if (!candidates.length) return;
let flipped = 0;
for (const t of candidates) {
try {
const res = await fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: _tmuxCmd(t, `has-session -t ${t.sessionId}`), timeout: 5 }),
});
const data = await res.json();
if (data.exit_code === 0) {
// Session still alive → the task is actually still running.
const fresh = _loadTasks();
const ft = fresh.find(x => x.sessionId === t.sessionId);
if (ft && ft.status !== 'running') {
ft.status = 'running';
ft._selfHealed = true;
_saveTasks(fresh);
flipped++;
const _el = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`);
if (_el) {
const _chk = _el.querySelector('.cookbook-task-check');
if (_chk) _chk.style.display = 'none';
const _wave = _el.querySelector('.cookbook-task-wave');
if (_wave) _wave.style.display = '';
const _up = _el.querySelector('.cookbook-task-uptime');
if (_up) _up.style.display = '';
_el.dataset.status = 'running';
}
}
}
} catch { /* network blip — skip this one */ }
}
if (flipped) {
console.log(`[cookbook] auto-reconnect: revived ${flipped} task(s) whose tmux session was still alive`);
_renderRunningTab();
}
}
export function _startBackgroundMonitor() { export function _startBackgroundMonitor() {
if (_bgMonitorInterval) return; if (_bgMonitorInterval) return;
_bgMonitorInterval = setInterval(() => { _pollBackgroundStatus(); _checkServeReachability(); }, BG_MONITOR_INTERVAL_MS); _bgMonitorInterval = setInterval(() => {
_pollBackgroundStatus();
_checkServeReachability();
// Auto-reconnect: every cycle, look for download tasks marked finished/
// crashed/etc. whose tmux session is actually still running, and flip
// them back to running. Internally throttled to 8s so a manual call from
// the open path or a fast invocation doesn't double up.
_selfHealStaleTasks().catch(() => {});
}, BG_MONITOR_INTERVAL_MS);
_pollBackgroundStatus(); _pollBackgroundStatus();
_checkServeReachability(); _checkServeReachability();
} }
+47 -5
View File
@@ -560,6 +560,15 @@ function _rerenderCachedModels() {
+ `</div>`; + `</div>`;
let panelHtml = `<div class="hwfit-serve-panel">${_slotsHtml}`; let panelHtml = `<div class="hwfit-serve-panel">${_slotsHtml}`;
// Warn when serving a model whose download hasn't fully completed —
// the user CAN still hit Launch (vLLM/llama-server will start, then
// crash trying to read missing shards), but they should know.
if (m && (m.status === 'downloading' || m.status === 'stalled' || m.has_incomplete)) {
const _warnText = m.status === 'stalled'
? `This model looks like a stale download shell (${esc(m.size || '0 KB')}). The weights aren't on disk — the serve will fail to load. Re-download first, or pick another model.`
: `This model's download isn't complete yet (${esc(m.size || 'partial')}). The serve will start but is likely to crash on a missing shard. Wait for the download to finish, or relaunch after it's done.`;
panelHtml += `<div class="hwfit-serve-warn" style="margin:0 0 8px;padding:6px 10px;border-radius:5px;font-size:11px;background:color-mix(in srgb, var(--color-warning, #f0ad4e) 14%, transparent);border:1px solid color-mix(in srgb, var(--color-warning, #f0ad4e) 40%, transparent);color:var(--color-warning, #f0ad4e);display:flex;gap:6px;align-items:flex-start;line-height:1.4;"><span aria-hidden="true">⚠</span><span>${_warnText}</span></div>`;
}
// Row 1: Backend + Server + Env // Row 1: Backend + Server + Env
panelHtml += `<div class="hwfit-serve-row">`; panelHtml += `<div class="hwfit-serve-row">`;
const _backendChoices = _isWindows() const _backendChoices = _isWindows()
@@ -595,13 +604,15 @@ function _rerenderCachedModels() {
// Row 2: Core settings // Row 2: Core settings
panelHtml += `<div class="hwfit-serve-row hwfit-backend-vllm hwfit-backend-sglang hwfit-backend-llamacpp">`; panelHtml += `<div class="hwfit-serve-row hwfit-backend-vllm hwfit-backend-sglang hwfit-backend-llamacpp">`;
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang">${_l('TP','Tensor Parallelism — split model across N GPUs')}<select class="hwfit-sf" data-field="tp">${tpOpts}</select></label>`; panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang">${_l('TP','Tensor Parallelism — split model across N GPUs')}<select class="hwfit-sf" data-field="tp">${tpOpts}</select></label>`;
panelHtml += `<label>${_l('Context','Max tokens per request. Lower = less VRAM')}<input type="text" class="hwfit-sf" data-field="ctx" value="${esc(sv('ctx', '8192'))}" /></label>`; // ctx resets to the model's max on every panel open (the real ctx slider
// lives in the Scan/Download toolbar — see cookbook.js .hwfit-ctx-control).
panelHtml += `<label>${_l('Context','Max tokens per request — resets to the model max on every open. Lower = less VRAM')}<input type="text" class="hwfit-sf" data-field="ctx" value="${esc(m.context_length || m.context || '20000')}" /></label>`;
panelHtml += `<label>${_l('GPU','Which GPU to use. Leave empty for default')}<input type="text" class="hwfit-sf" data-field="gpu_id" value="${esc(sv('gpu_id', ''))}" placeholder="auto" style="width:50px;" /></label>`; panelHtml += `<label>${_l('GPU','Which GPU to use. Leave empty for default')}<input type="text" class="hwfit-sf" data-field="gpu_id" value="${esc(sv('gpu_id', ''))}" placeholder="auto" style="width:50px;" /></label>`;
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang">${_l('GPU Mem','Fraction of GPU memory (0.0–1.0). Lower if OOM')}<input type="text" class="hwfit-sf" data-field="gpu_mem" value="${esc(sv('gpu_mem', '0.90'))}" /></label>`; panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang">${_l('GPU Mem','Fraction of GPU memory (0.0–1.0). Lower if OOM')}<input type="text" class="hwfit-sf" data-field="gpu_mem" value="${esc(sv('gpu_mem', '0.90'))}" /></label>`;
panelHtml += `<label class="hwfit-backend-vllm">${_l('Swap','CPU swap space in GB. Leave empty to omit (removed in newer vLLM)')}<input type="text" class="hwfit-sf" data-field="swap" value="${esc(sv('swap', ''))}" placeholder="off" /></label>`; panelHtml += `<label class="hwfit-backend-vllm">${_l('Swap','CPU swap space in GB. Leave empty to omit (removed in newer vLLM)')}<input type="text" class="hwfit-sf" data-field="swap" value="${esc(sv('swap', ''))}" placeholder="off" /></label>`;
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang">${_l('Max Seqs','Maximum concurrent requests. Lower = less memory. Default 8 — prosumer GPUs often OOM on vLLM default 256 during CUDA graph capture.')}<input type="text" class="hwfit-sf" data-field="max_seqs" value="${esc(sv('max_seqs', '8'))}" placeholder="8" /></label>`; panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang">${_l('Max Seqs','Maximum concurrent requests. Lower = less memory. Default 4 — prosumer GPUs often OOM on vLLM default 256 during CUDA graph capture.')}<input type="text" class="hwfit-sf" data-field="max_seqs" value="${esc(sv('max_seqs', '4'))}" placeholder="4" /></label>`;
panelHtml += `<label>${_l('Dtype','Data type for weights. auto picks best for GPU')}<select class="hwfit-sf" data-field="dtype">${dtypeOpts}</select></label>`; panelHtml += `<label>${_l('Dtype','Data type for weights. auto picks best for GPU')}<select class="hwfit-sf" data-field="dtype">${dtypeOpts}</select></label>`;
panelHtml += `<label class="hwfit-backend-vllm">${_l('KV Cache','vLLM --kv-cache-dtype. auto uses the model/runtime default; fp8 reduces KV memory for long context.')}<select class="hwfit-sf" data-field="vllm_kv_cache_dtype">${vllmKvCacheOpts}</select></label>`; panelHtml += `<label class="hwfit-backend-vllm">${_l('KV Cache','vLLM --kv-cache-dtype. auto uses the model/runtime default; fp8 reduces KV memory for long context.')}<select class="hwfit-sf" data-field="vllm_kv_cache_dtype" style="height:32px;">${vllmKvCacheOpts}</select></label>`;
panelHtml += `</div>`; panelHtml += `</div>`;
// Row 2b: Diffusers settings // Row 2b: Diffusers settings
const diffDtypeOpts = ['bfloat16','float16','float32'].map(d => `<option value="${d}"${sv('diff_dtype','bfloat16')===d?' selected':''}>${d}</option>`).join(''); const diffDtypeOpts = ['bfloat16','float16','float32'].map(d => `<option value="${d}"${sv('diff_dtype','bfloat16')===d?' selected':''}>${d}</option>`).join('');
@@ -694,7 +705,7 @@ function _rerenderCachedModels() {
if (!_specMethods.includes(_specMethod)) _specMethods.unshift(_specMethod); if (!_specMethods.includes(_specMethod)) _specMethods.unshift(_specMethod);
const _specOpts = _specMethods.map(m => const _specOpts = _specMethods.map(m =>
`<option value="${m}"${m === _specMethod ? ' selected' : ''}>${m}</option>`).join(''); `<option value="${m}"${m === _specMethod ? ' selected' : ''}>${m}</option>`).join('');
panelHtml += `<label class="hwfit-sf-cb hwfit-spec-group"><input type="checkbox" class="hwfit-sf" data-field="speculative" /> Speculative <select class="hwfit-sf hwfit-spec-method" data-field="spec_method" title="vLLM --speculative-config method">${_specOpts}</select><span class="hwfit-numstep"><button type="button" class="hwfit-numstep-btn" data-step="-1" tabindex="-1" aria-label="Decrease">‹</button><input type="number" class="hwfit-sf hwfit-spec-tokens" data-field="spec_tokens" value="${esc(_specTokens)}" min="1" max="10" title="num_speculative_tokens" /><button type="button" class="hwfit-numstep-btn" data-step="1" tabindex="-1" aria-label="Increase">›</button></span></label>`; panelHtml += `<label class="hwfit-sf-cb hwfit-spec-group"><input type="checkbox" class="hwfit-sf" data-field="speculative" /> Speculative <select class="hwfit-sf hwfit-spec-method" data-field="spec_method" title="vLLM --speculative-config method">${_specOpts}</select><span class="hwfit-numstep"><button type="button" class="hwfit-numstep-btn" data-step="-1" tabindex="-1" aria-label="Decrease">‹</button><input type="number" class="hwfit-sf hwfit-spec-tokens" data-field="spec_tokens" value="${esc(_specTokens)}" min="1" max="10" title="num_speculative_tokens" /><button type="button" class="hwfit-numstep-btn" data-step="1" tabindex="-1" aria-label="Increase">›</button></span><span class="hwfit-help-chip hwfit-help-chip-inline" title="MTP / speculative decoding is supported on a few model families only — turn it on when the model card explicitly recommends it. On supported models it can boost inference throughput up to ~3×; on unsupported models it will either be ignored or fail to launch." style="margin-left:6px;">?</span></label>`;
} }
if (_opts2.envVars.length) panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="moe_env" /> MoE Env Vars</label>`; if (_opts2.envVars.length) panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="moe_env" /> MoE Env Vars</label>`;
panelHtml += `</div>`; panelHtml += `</div>`;
@@ -719,7 +730,7 @@ function _rerenderCachedModels() {
// pushes Cancel + Launch to the right. // pushes Cancel + Launch to the right.
panelHtml += `<span class="hwfit-serve-actions-spacer"></span>`; panelHtml += `<span class="hwfit-serve-actions-spacer"></span>`;
panelHtml += `<button class="cookbook-btn hwfit-serve-cancel" type="button" title="Close this configuration panel">Cancel</button>`; panelHtml += `<button class="cookbook-btn hwfit-serve-cancel" type="button" title="Close this configuration panel">Cancel</button>`;
panelHtml += `<button class="cookbook-btn hwfit-serve-launch">Launch</button>`; panelHtml += `<button class="cookbook-btn hwfit-serve-launch"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;flex-shrink:0;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Launch</button>`;
panelHtml += `</div>`; panelHtml += `</div>`;
panelHtml += `</div>`; panelHtml += `</div>`;
@@ -1655,6 +1666,37 @@ function _rerenderCachedModels() {
}); });
return; return;
} }
// Pre-launch GPU probe — common failure pattern: vLLM/SGLang launched
// on a host where no GPU is visible (driver missing, $CUDA_VISIBLE_DEVICES
// unset, container without --gpus). Catch it BEFORE the user spends
// minutes watching the task fail.
const _needsGpu = ['vllm', 'sglang'].includes(serveState.backend)
|| (serveState.backend === 'diffusers');
if (_needsGpu) {
try {
const _probeHost = (_envState.remoteHost || '').trim();
const _probeParams = new URLSearchParams();
if (_probeHost) {
_probeParams.set('host', _probeHost);
const _sp = (_envState.servers || []).find(s => s.host === _probeHost)?.port;
if (_sp) _probeParams.set('ssh_port', _sp);
}
const _probeRes = await fetch('/api/cookbook/gpus' + (_probeParams.toString() ? '?' + _probeParams : ''), { credentials: 'same-origin' });
const _probeData = await _probeRes.json();
const _probeGpus = Array.isArray(_probeData) ? _probeData : (_probeData.gpus || []);
if (!_probeGpus.length) {
const _proceed = await window.styledConfirm(
`No GPU detected on ${_probeHost ? _probeHost : 'this host'}. ${serveState.backend.toUpperCase()} needs a visible CUDA/ROCm accelerator to start — launching now will most likely crash early.\n\nLaunch anyway?`,
{ title: 'No GPU detected', confirmText: 'Launch anyway', cancelText: 'Cancel', danger: true },
);
if (!_proceed) return;
}
} catch {
// Network / probe failure — don't block. Better to let the launch
// proceed than to silently refuse because the probe endpoint
// hiccuped (the user can read the real error in the task output).
}
}
// Save in the { _byRepo, _lastUsed } schema — no legacy flat keys at // Save in the { _byRepo, _lastUsed } schema — no legacy flat keys at
// the root so per-model state doesn't leak between models. // the root so per-model state doesn't leak between models.
try { try {
+3 -19
View File
@@ -519,20 +519,7 @@ export function getAutoScroll() {
export function autoResize(textarea) { export function autoResize(textarea) {
const lineHeight = parseInt(getComputedStyle(textarea).lineHeight); const lineHeight = parseInt(getComputedStyle(textarea).lineHeight);
const isMobile = window.innerWidth <= 768; const isMobile = window.innerWidth <= 768;
const autoMaxHeight = isMobile ? 150 : lineHeight * 8; const maxHeight = isMobile ? 150 : lineHeight * 8;
// Keep a height chosen with the native desktop resize handle. Automatic
// changes are recorded before the observer runs, so only a real drag
// updates the manual floor.
if (!textarea._manualResizeObserver && typeof ResizeObserver !== 'undefined') {
textarea._manualResizeObserver = new ResizeObserver(() => {
const height = textarea.offsetHeight;
if (Math.abs(height - (textarea._autoResizeHeight || height)) > 1) {
textarea._manualResizeHeight = height;
}
});
textarea._manualResizeObserver.observe(textarea);
}
// Use a hidden clone to measure without disrupting the real textarea // Use a hidden clone to measure without disrupting the real textarea
let clone = textarea._resizeClone; let clone = textarea._resizeClone;
@@ -552,12 +539,9 @@ export function autoResize(textarea) {
clone.style.width = textarea.offsetWidth + 'px'; clone.style.width = textarea.offsetWidth + 'px';
clone.value = textarea.value; clone.value = textarea.value;
clone.style.height = '0'; clone.style.height = '0';
const manualHeight = textarea._manualResizeHeight || 0; const newHeight = Math.min(Math.max(clone.scrollHeight, lineHeight), maxHeight);
const maxHeight = Math.max(autoMaxHeight, manualHeight);
const newHeight = Math.min(Math.max(clone.scrollHeight, lineHeight, manualHeight), maxHeight);
textarea._autoResizeHeight = newHeight;
textarea.style.height = newHeight + 'px'; textarea.style.height = newHeight + 'px';
textarea.style.overflow = newHeight >= autoMaxHeight ? 'auto' : 'hidden'; textarea.style.overflow = newHeight >= maxHeight ? 'auto' : 'hidden';
} }
/** /**
+219 -12
View File
@@ -2204,12 +2204,12 @@ body.bg-pattern-sparkles {
background: transparent; background: transparent;
border: none; border: none;
outline: none; outline: none;
resize: vertical; resize: none;
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
color: var(--fg); color: var(--fg);
min-height: 24px; min-height: 24px;
max-height: min(60vh, 600px); max-height: 200px;
padding: 0; padding: 0;
font-family: inherit; font-family: inherit;
transition: height 0.12s ease-out; transition: height 0.12s ease-out;
@@ -18628,16 +18628,41 @@ body.gallery-selecting .gallery-dl-btn,
background: color-mix(in srgb, var(--fg) 10%, transparent); background: color-mix(in srgb, var(--fg) 10%, transparent);
color: color-mix(in srgb, var(--fg) 60%, transparent); color: color-mix(in srgb, var(--fg) 60%, transparent);
} }
/* Rebuild tag — same look as the LLM category tag, sits to its left. */
.cookbook-dep-rebuild {
background: color-mix(in srgb, var(--fg) 10%, transparent);
color: color-mix(in srgb, var(--fg) 75%, transparent);
border: 1px solid color-mix(in srgb, var(--fg) 20%, transparent);
cursor: pointer;
font-family: inherit;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
}
.cookbook-dep-rebuild:hover {
background: color-mix(in srgb, var(--accent, var(--red)) 18%, transparent);
color: var(--accent, var(--red));
border-color: color-mix(in srgb, var(--accent, var(--red)) 45%, transparent);
}
.cookbook-dep-installed { .cookbook-dep-installed {
background: color-mix(in srgb, var(--green, #50fa7b) 18%, transparent); background: color-mix(in srgb, var(--green, #50fa7b) 18%, transparent);
color: var(--green, #50fa7b); color: var(--green, #50fa7b);
border: 1px solid color-mix(in srgb, var(--green, #50fa7b) 35%, transparent); border: 1px solid color-mix(in srgb, var(--green, #50fa7b) 35%, transparent);
/* Match the Install button + Installed ▾ split width so all three variants
align in a mixed row. */
min-width: 75.85px;
padding: 0 10px;
box-sizing: border-box;
} }
.cookbook-dep-na { .cookbook-dep-na {
background: color-mix(in srgb, var(--fg) 8%, transparent); background: color-mix(in srgb, var(--fg) 8%, transparent);
color: color-mix(in srgb, var(--fg) 60%, transparent); color: color-mix(in srgb, var(--fg) 60%, transparent);
border: 1px solid color-mix(in srgb, var(--fg) 16%, transparent); border: 1px solid color-mix(in srgb, var(--fg) 16%, transparent);
cursor: help; cursor: help;
/* Match other dep tag widths so N/A rows line up with Install / Installed. */
min-width: 75.85px;
padding: 0 10px;
box-sizing: border-box;
} }
.cookbook-dep-install { .cookbook-dep-install {
background: var(--accent, var(--red)); background: var(--accent, var(--red));
@@ -18648,12 +18673,30 @@ body.gallery-selecting .gallery-dl-btn,
font-weight: 500; font-weight: 500;
position: relative; position: relative;
top: -3px; top: -3px;
/* Width matches the measured Installed ▾ split button (75.85px) so a row of
mixed Install / Installed deps lines up. */
min-width: 75.85px;
padding: 0 10px;
/* Strip the native button box so it's the same height as the sibling tags /* Strip the native button box so it's the same height as the sibling tags
(Firefox renders <button> taller otherwise); height comes from .cookbook-dep-tag. */ (Firefox renders <button> taller otherwise); height comes from .cookbook-dep-tag. */
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
-moz-appearance: none; -moz-appearance: none;
} }
/* Conditional line under the Download h2: only when the section is folded
(collapsed). When expanded, the body content provides separation; the
underline reads as clutter. */
#cookbook-dl-tab-fold { border-bottom: none !important; padding-bottom: 0 !important; }
#cookbook-dl-tab-fold.is-folded {
border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent) !important;
padding-bottom: 6px !important;
}
/* Center the "?" glyph inside the help chip. Without text-align it sits 0.5px
left of true center because of the character's natural baseline offset. */
.hwfit-help-chip {
text-align: center;
padding-left: 0.5px;
}
.cookbook-dep-install:hover { opacity: 0.85; } .cookbook-dep-install:hover { opacity: 0.85; }
/* Installed split button: "Installed" label + separator + ▾ caret; clicking it /* Installed split button: "Installed" label + separator + ▾ caret; clicking it
opens the actions menu (Update). Replaces the old ⋮ button. */ opens the actions menu (Update). Replaces the old ⋮ button. */
@@ -18696,6 +18739,94 @@ body.gallery-selecting .gallery-dl-btn,
display: block; display: block;
margin-top: 2px; margin-top: 2px;
} }
/* Ctx slider — ported from origin/main. Sits in the Scan/Download toolbar
next to the quant dropdown. Drives _ctxValue() in cookbook-hwfit.js. */
.hwfit-ctx-control {
height: 28px;
min-width: 134px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0 7px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
font-size: 12px; /* match .cookbook-field-input so Context reads same size as Engine/Quant */
}
.hwfit-ctx-control span {
/* Match Quant/Engine select label style: no uppercase, no letter-spacing. */
text-transform: none;
letter-spacing: 0;
opacity: 0.9;
}
/* Editor-style slider (same look as the gallery editor sliders): thin pill
rail that fattens on interaction, circular red thumb that grows on hover. */
.hwfit-ctx-control input[type="range"] {
width: 64px;
min-width: 64px;
height: 4px;
padding: 0;
border: 0;
-webkit-appearance: none;
appearance: none;
/* Hard-coded grey so the rail is GUARANTEED visible regardless of theme —
every theme-derived color we tried (--fg-muted, --border, accent-bg mix)
kept blending into the panel background on at least one theme. */
background: rgba(150, 150, 150, 0.65);
border-radius: 999px;
accent-color: var(--red);
cursor: pointer;
transition: height 0.15s ease, background 0.15s ease;
}
.hwfit-ctx-control input[type="range"]:hover,
.hwfit-ctx-control input[type="range"]:focus,
.hwfit-ctx-control input[type="range"]:active {
background: var(--fg);
}
.hwfit-ctx-control input[type="range"]:hover,
.hwfit-ctx-control input[type="range"]:focus,
.hwfit-ctx-control input[type="range"]:active {
height: 10px;
}
.hwfit-ctx-control input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--red);
border: none;
cursor: pointer;
transition: width 0.12s ease, height 0.12s ease;
}
.hwfit-ctx-control input[type="range"]::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--red);
border: none;
cursor: pointer;
transition: width 0.12s ease, height 0.12s ease;
}
.hwfit-ctx-control input[type="range"]:hover::-webkit-slider-thumb,
.hwfit-ctx-control input[type="range"]:focus::-webkit-slider-thumb,
.hwfit-ctx-control input[type="range"]:active::-webkit-slider-thumb {
width: 18px;
height: 18px;
}
.hwfit-ctx-control input[type="range"]:hover::-moz-range-thumb,
.hwfit-ctx-control input[type="range"]:focus::-moz-range-thumb,
.hwfit-ctx-control input[type="range"]:active::-moz-range-thumb {
width: 18px;
height: 18px;
}
.hwfit-ctx-control output {
min-width: 28px;
text-align: right;
color: var(--fg);
font-weight: 600;
}
.hwfit-sf { .hwfit-sf {
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -19245,9 +19376,12 @@ body.gallery-selecting .gallery-dl-btn,
position: relative; position: relative;
top: -4px; top: -4px;
cursor: pointer; cursor: pointer;
padding: 1px 6px 1px 4px; /* Tightened vertical padding so the hover-background isn't disproportionately
tall vs the icon+label. */
padding: 0 6px 0 4px;
height: 14px;
border: 0; border: 0;
border-radius: 9px; border-radius: 7px;
background: transparent; background: transparent;
color: var(--fg); color: var(--fg);
font-family: inherit; font-family: inherit;
@@ -19949,6 +20083,17 @@ body.gallery-selecting .gallery-dl-btn,
border-color: var(--color-error); border-color: var(--color-error);
background: color-mix(in srgb, var(--color-error) 12%, transparent); background: color-mix(in srgb, var(--color-error) 12%, transparent);
} }
/* Icons on the left of diagnosis action buttons (Retry / Copy / Edit / etc.). */
.cookbook-diag-btn,
.cookbook-diag-menu button {
display: inline-flex;
align-items: center;
gap: 5px;
}
.cookbook-diag-btn-ico {
flex-shrink: 0;
opacity: 0.9;
}
/* ── What Fits? (hardware model fitting tab in cookbook) ── */ /* ── What Fits? (hardware model fitting tab in cookbook) ── */
.cookbook-group.hidden { display: none !important; } .cookbook-group.hidden { display: none !important; }
@@ -20421,6 +20566,40 @@ body.gallery-selecting .gallery-dl-btn,
.hwfit-toolbar .hwfit-usecase { min-width: 70px; flex-shrink: 0; } .hwfit-toolbar .hwfit-usecase { min-width: 70px; flex-shrink: 0; }
.hwfit-toolbar .hwfit-quant { min-width: 50px; flex-shrink: 0; } .hwfit-toolbar .hwfit-quant { min-width: 50px; flex-shrink: 0; }
.hwfit-toolbar .hwfit-search { flex: 1; min-width: 80px; } .hwfit-toolbar .hwfit-search { flex: 1; min-width: 80px; }
/* Lower-opacity "Search models..." placeholder so it reads as a hint, not
a label — matches the muted form-field feel of the inline filters. */
.hwfit-search::placeholder { opacity: 0.5; }
.hwfit-search::-webkit-input-placeholder { opacity: 0.5; }
.hwfit-search::-moz-placeholder { opacity: 0.5; }
/* Dot inside the Fit column header — click to toggle the fit-only filter
(off = show too-tight rows; on = hide them). */
.hwfit-fit-dot {
display: inline-block;
margin-right: 4px;
font-size: 8px;
line-height: 1;
color: color-mix(in srgb, var(--fg) 35%, transparent);
cursor: pointer;
vertical-align: middle;
position: relative;
top: -1px; /* nudge 1px up so the small dot sits centered with the "Fit" caps */
transition: color 0.12s ease, text-shadow 0.12s ease;
}
/* Quant suffix appended to model names when the storage format isn't in the
repo id — e.g. "(FP4-MoE-Mixed)" after DeepSeek-V4-Flash. Muted to read as
metadata, not part of the name. */
.hwfit-name-quant {
font-size: 0.78em;
opacity: 0.55;
font-weight: 400;
margin-left: 4px;
}
.hwfit-fit-dot:hover { color: var(--accent, var(--red)); }
.hwfit-fit-dot.active {
color: var(--green, #50fa7b);
text-shadow: 0 0 4px color-mix(in srgb, var(--green, #50fa7b) 55%, transparent);
}
.hwfit-help-chip { .hwfit-help-chip {
width: 14px; width: 14px;
height: 14px; height: 14px;
@@ -20447,6 +20626,28 @@ body.gallery-selecting .gallery-dl-btn,
.hwfit-help-chip-inline { .hwfit-help-chip-inline {
margin-left: -2px; margin-left: -2px;
margin-right: 0; margin-right: 0;
top: 0; /* parent rule sets top:-1px; nudge inline variant 1px lower */
}
/* Quant select + inline ? wrapper — the ? sits inside the dropdown's bordered
box, anchored on the right just left of the chevron. */
.hwfit-quant-wrap, .hwfit-engine-wrap {
position: relative;
display: inline-flex;
align-items: center;
}
.hwfit-quant-wrap .hwfit-quant,
.hwfit-engine-wrap .hwfit-engine {
/* Make room for the ? on the right edge, in addition to the native chevron. */
padding-right: 32px;
}
.hwfit-quant-wrap .hwfit-quant-help,
.hwfit-engine-wrap .hwfit-engine-help {
position: absolute;
right: 20px; /* sits just left of the native select chevron */
top: 50%;
transform: translateY(-50%);
pointer-events: auto;
margin: 0;
} }
.hwfit-ctx-control { .hwfit-ctx-control {
height: 28px; height: 28px;
@@ -20460,21 +20661,27 @@ body.gallery-selecting .gallery-dl-btn,
border-radius: 4px; border-radius: 4px;
color: var(--fg-muted); color: var(--fg-muted);
background: var(--bg); background: var(--bg);
font-size: 10px; font-size: 12px; /* match .cookbook-field-input — was 10px and read smaller than siblings */
box-sizing: border-box; box-sizing: border-box;
} }
.hwfit-ctx-control span { .hwfit-ctx-control span {
text-transform: uppercase; /* Match Quant/Engine select label style: no uppercase, no letter-spacing. */
letter-spacing: 0.3px; text-transform: none;
opacity: 0.75; letter-spacing: 0;
opacity: 0.9;
} }
.hwfit-ctx-control input[type="range"] { .hwfit-ctx-control input[type="range"] {
width: 54px; width: 64px;
min-width: 54px; min-width: 64px;
height: 16px; height: 4px;
padding: 0; padding: 0;
border: 0; border: 0;
background: transparent; -webkit-appearance: none;
appearance: none;
/* Hardcoded grey rail — was background:transparent here, which was the
LATER-in-cascade override that kept making the rail invisible. */
background: rgba(150, 150, 150, 0.65) !important;
border-radius: 999px;
accent-color: var(--accent, var(--red)); accent-color: var(--accent, var(--red));
} }
.hwfit-ctx-control output { .hwfit-ctx-control output {
+401
View File
@@ -0,0 +1,401 @@
import ipaddress
import importlib.util
import sys
import types
from pathlib import Path
import pytest
@pytest.mark.parametrize("url", [
"http://127.0.0.1:8000/v1",
"http://localhost:8000/v1",
"http://10.0.0.5/v1",
"http://172.16.0.1/v1",
"http://192.168.1.2/v1",
"http://169.254.169.254/latest/meta-data/",
"http://metadata.google.internal/",
"http://[::1]:8000/v1",
"http://[fc00::1]/v1",
"http://224.0.0.1/v1",
"http://0.0.0.0/v1",
"file:///etc/passwd",
])
def test_public_url_validator_blocks_internal_targets(url):
from src.url_security import is_public_http_url
assert is_public_http_url(url) is False
def test_public_url_validator_allows_public_endpoint(monkeypatch):
from src import url_security
monkeypatch.setattr(
url_security,
"_resolve_hostname_ips",
lambda host: [ipaddress.ip_address("93.184.216.34")],
)
assert url_security.validate_public_http_url("https://api.example.com/v1") == "https://api.example.com/v1"
def test_public_url_validator_blocks_dns_to_private(monkeypatch):
from src import url_security
monkeypatch.setattr(
url_security,
"_resolve_hostname_ips",
lambda host: [ipaddress.ip_address("10.0.0.5")],
)
with pytest.raises(ValueError):
url_security.validate_public_http_url("https://api.example.com/v1")
def _load_webhook_routes_for_test(monkeypatch):
# Load under a unique module name so each test gets a fresh module object
# rather than a cached one from a previous monkeypatch run.
core_pkg = types.ModuleType("core")
core_pkg.__path__ = []
core_db = types.ModuleType("core.database")
core_db.SessionLocal = object
core_db.Webhook = object
core_db.ModelEndpoint = object
core_middleware = types.ModuleType("core.middleware")
core_middleware.require_admin = lambda request: None
webhook_manager = types.ModuleType("src.webhook_manager")
webhook_manager.WebhookManager = object
webhook_manager.validate_webhook_url = lambda url: url
webhook_manager.validate_events = lambda events: events
monkeypatch.setitem(sys.modules, "core", core_pkg)
monkeypatch.setitem(sys.modules, "core.database", core_db)
monkeypatch.setitem(sys.modules, "core.middleware", core_middleware)
monkeypatch.setitem(sys.modules, "src.webhook_manager", webhook_manager)
module_name = "routes.webhook_routes_under_test"
spec = importlib.util.spec_from_file_location(
module_name,
Path(__file__).resolve().parent.parent / "routes" / "webhook_routes.py",
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class _Expr:
def __init__(self, fn):
self.fn = fn
def __call__(self, row):
return self.fn(row)
def __or__(self, other):
return _Expr(lambda row: self(row) or other(row))
class _Column:
def __init__(self, name):
self.name = name
def __eq__(self, other):
return _Expr(lambda row: getattr(row, self.name) == other)
def desc(self):
return ("desc", self.name)
class _ModelEndpoint:
is_enabled = _Column("is_enabled")
owner = _Column("owner")
created_at = _Column("created_at")
class _Endpoint:
def __init__(
self,
*,
owner,
is_enabled=True,
created_at=1,
base_url="https://api.example.com/v1",
api_key=None,
):
self.owner = owner
self.is_enabled = is_enabled
self.created_at = created_at
self.base_url = base_url
self.api_key = api_key
class _EndpointQuery:
def __init__(self, rows):
self.rows = rows
self.filters = []
self.orders = []
def filter(self, *exprs):
self.filters.extend(exprs)
return self
def order_by(self, *exprs):
self.orders.extend(exprs)
return self
def first(self):
rows = self.rows
for expr in self.filters:
rows = [row for row in rows if expr(row)]
# Apply sort keys right-to-left so the leftmost key ends up as the
# primary sort (stable-sort reversal idiom mirrors SQLAlchemy's
# multi-column ORDER BY behaviour).
for order in reversed(self.orders):
reverse = False
name = getattr(order, "name", None)
if isinstance(order, tuple) and order[0] == "desc":
reverse = True
name = order[1]
rows = sorted(rows, key=lambda row: getattr(row, name) is not None, reverse=reverse)
if name != "owner":
rows = sorted(rows, key=lambda row: getattr(row, name), reverse=reverse)
return rows[0] if rows else None
class _DB:
def __init__(self, rows):
self.query_obj = _EndpointQuery(rows)
self.closed = False
def query(self, model):
assert model is _ModelEndpoint
return self.query_obj
def close(self):
self.closed = True
class _ChatSession:
def __init__(self, endpoint_url, model):
self.endpoint_url = endpoint_url
self.model = model
self.headers = {}
self.history = []
def add_message(self, message):
self.history.append(message)
class _SessionManager:
def __init__(self):
self.created = []
self.save_calls = 0
def create_session(self, *, session_id, name, endpoint_url, model, owner):
session = _ChatSession(endpoint_url, model)
self.created.append({
"session_id": session_id,
"name": name,
"endpoint_url": endpoint_url,
"model": model,
"owner": owner,
"session": session,
})
return session
def save_sessions(self):
self.save_calls += 1
class _Request:
def __init__(self, *, owner="alice"):
self.state = types.SimpleNamespace(
api_token=True,
api_token_scopes=["chat"],
api_token_owner=owner,
)
class _WebhookManager:
async def fire(self, event, payload):
return None
def _install_sync_chat_stubs(monkeypatch):
# FastAPI checks for python_multipart at import time when Form is used;
# stub it so the optional dependency is not required in the test environment.
python_multipart = types.ModuleType("python_multipart")
python_multipart.__version__ = "0.0.13"
core_models = types.ModuleType("core.models")
class _ChatMessage:
def __init__(self, role, content):
self.role = role
self.content = content
async def _llm_call_async(endpoint_url, model, messages, headers=None, timeout=None):
return "mocked response"
endpoint_resolver = types.ModuleType("src.endpoint_resolver")
endpoint_resolver.normalize_base = lambda url: (url or "").strip().rstrip("/")
endpoint_resolver.build_chat_url = lambda base_url: f"{base_url}/chat/completions"
endpoint_resolver.build_models_url = lambda base_url: f"{base_url}/models"
endpoint_resolver.build_headers = lambda api_key, base_url: {"Authorization": f"Bearer {api_key}"}
llm_core = types.ModuleType("src.llm_core")
llm_core.llm_call_async = _llm_call_async
core_models.ChatMessage = _ChatMessage
monkeypatch.setitem(sys.modules, "python_multipart", python_multipart)
monkeypatch.setitem(sys.modules, "core.models", core_models)
monkeypatch.setitem(sys.modules, "src.llm_core", llm_core)
monkeypatch.setitem(sys.modules, "src.endpoint_resolver", endpoint_resolver)
def _sync_chat_endpoint(webhook_routes, session_manager):
router = webhook_routes.setup_webhook_routes(
_WebhookManager(),
auth_manager=None,
session_manager=session_manager,
)
for route in router.routes:
if route.path == "/api/v1/chat":
return route.endpoint
raise AssertionError("sync chat route not found")
@pytest.mark.parametrize("base_url", [
"http://127.0.0.1:11434/v1",
"http://localhost:11434/v1",
"http://10.0.0.5/v1",
"http://169.254.169.254/latest/meta-data/",
])
@pytest.mark.asyncio
async def test_api_chat_direct_base_url_rejects_local_private_targets(monkeypatch, base_url):
webhook_routes = _load_webhook_routes_for_test(monkeypatch)
_install_sync_chat_stubs(monkeypatch)
session_manager = _SessionManager()
sync_chat = _sync_chat_endpoint(webhook_routes, session_manager)
body = types.SimpleNamespace(
message="hello",
api_key="test-key",
base_url=base_url,
model="test-model",
provider=None,
session=None,
)
with pytest.raises(webhook_routes.HTTPException) as exc:
await sync_chat(_Request(), body)
assert exc.value.status_code == 400
assert exc.value.detail == "base_url must point to a public HTTP(S) endpoint"
assert session_manager.created == []
@pytest.mark.asyncio
async def test_api_chat_direct_base_url_allows_mocked_public_endpoint(monkeypatch):
webhook_routes = _load_webhook_routes_for_test(monkeypatch)
_install_sync_chat_stubs(monkeypatch)
from src import url_security
monkeypatch.setattr(
url_security,
"_resolve_hostname_ips",
lambda host: [ipaddress.ip_address("93.184.216.34")],
)
session_manager = _SessionManager()
sync_chat = _sync_chat_endpoint(webhook_routes, session_manager)
body = types.SimpleNamespace(
message="hello",
api_key="test-key",
base_url="https://api.example.com/v1",
model="test-model",
provider=None,
session=None,
)
response = await sync_chat(_Request(), body)
assert response["response"] == "mocked response"
assert response["model"] == "test-model"
assert session_manager.created[0]["endpoint_url"] == "https://api.example.com/v1/chat/completions"
def test_api_chat_fallback_endpoint_selection_for_owned_token(monkeypatch):
webhook_routes = _load_webhook_routes_for_test(monkeypatch)
rows = [
_Endpoint(owner="alice", is_enabled=False, created_at=0),
_Endpoint(owner="bob", created_at=0),
_Endpoint(owner=None, created_at=1),
_Endpoint(owner="alice", created_at=2),
]
monkeypatch.setattr(webhook_routes, "ModelEndpoint", _ModelEndpoint)
selected = webhook_routes._select_api_chat_fallback_endpoint(_DB(rows), "alice")
assert selected.owner == "alice"
assert selected.is_enabled is True
assert selected.created_at == 2
def test_api_chat_fallback_without_owner_uses_shared_only(monkeypatch):
webhook_routes = _load_webhook_routes_for_test(monkeypatch)
rows = [
_Endpoint(owner="alice", created_at=0),
_Endpoint(owner=None, is_enabled=False, created_at=1),
_Endpoint(owner=None, created_at=2),
]
monkeypatch.setattr(webhook_routes, "ModelEndpoint", _ModelEndpoint)
selected = webhook_routes._select_api_chat_fallback_endpoint(_DB(rows), None)
assert selected.owner is None
assert selected.is_enabled is True
assert selected.created_at == 2
@pytest.mark.asyncio
async def test_api_chat_fallback_trusts_configured_local_endpoint(monkeypatch):
webhook_routes = _load_webhook_routes_for_test(monkeypatch)
_install_sync_chat_stubs(monkeypatch)
local_endpoint = _Endpoint(
owner=None,
base_url="http://localhost:11434/v1",
api_key="configured-key",
)
db = _DB([local_endpoint])
calls = []
def _session_local():
return db
def _validate_public_http_url(url, *, max_length=2048):
calls.append(url)
raise AssertionError("configured fallback endpoint should not be publicly validated")
monkeypatch.setattr(webhook_routes, "ModelEndpoint", _ModelEndpoint)
monkeypatch.setattr(webhook_routes, "SessionLocal", _session_local)
monkeypatch.setattr(webhook_routes, "validate_public_http_url", _validate_public_http_url)
session_manager = _SessionManager()
sync_chat = _sync_chat_endpoint(webhook_routes, session_manager)
body = types.SimpleNamespace(
message="hello",
model="local-model",
api_key=None,
base_url=None,
provider=None,
session=None,
)
response = await sync_chat(_Request(owner=None), body)
assert response["response"] == "mocked response"
assert response["model"] == "local-model"
assert session_manager.created[0]["endpoint_url"] == "http://localhost:11434/v1/chat/completions"
assert calls == []
+147
View File
@@ -0,0 +1,147 @@
"""Guards the standalone GPU compose files against drift.
Stack-management UIs (Portainer, Coolify, Dockhand, ...) often accept only a
single compose file and do not honor COMPOSE_FILE or multiple ``-f`` overlays,
so the repo ships standalone ``docker-compose.gpu-*.yml`` files that inline the
GPU overlay. The base ``docker-compose.yml`` plus ``docker/gpu.*.yml`` overlays
remain the source of truth; these tests assert each standalone file equals the
base compose with only the matching overlay merged into the ``odysseus``
service. No Docker / docker compose is required — everything is pure YAML.
"""
import copy
from pathlib import Path
import pytest
import yaml
ROOT = Path(__file__).resolve().parents[1]
BASE = ROOT / "docker-compose.yml"
NVIDIA_OVERLAY = ROOT / "docker" / "gpu.nvidia.yml"
AMD_OVERLAY = ROOT / "docker" / "gpu.amd.yml"
NVIDIA_STANDALONE = ROOT / "docker-compose.gpu-nvidia.yml"
AMD_STANDALONE = ROOT / "docker-compose.gpu-amd.yml"
SERVICE = "odysseus"
def _load(path: Path) -> dict:
return yaml.safe_load(path.read_text(encoding="utf-8"))
def _deep_merge(base: dict, overlay: dict) -> dict:
"""Mirror docker compose overlay semantics for the keys these files use.
Mappings merge recursively; list-valued service fields are concatenated
(compose appends override sequences such as ``environment`` rather than
replacing them); scalars are overwritten. The overlays here only append to
``environment`` and add otherwise-absent keys (``deploy``, ``devices``,
``group_add``), so this keeps the expected merge explicit without invoking
docker compose.
"""
result = copy.deepcopy(base)
for key, value in overlay.items():
if isinstance(value, dict) and isinstance(result.get(key), dict):
result[key] = _deep_merge(result[key], value)
elif isinstance(value, list) and isinstance(result.get(key), list):
result[key] = copy.deepcopy(result[key]) + copy.deepcopy(value)
else:
result[key] = copy.deepcopy(value)
return result
def _merge_overlay_into_base(base: dict, overlay: dict) -> dict:
"""Build the expected standalone config: base + overlay on odysseus only."""
expected = copy.deepcopy(base)
overlay_service = overlay["services"][SERVICE]
expected["services"][SERVICE] = _deep_merge(
expected["services"][SERVICE], overlay_service
)
return expected
@pytest.fixture(scope="module")
def base():
return _load(BASE)
# --- Equivalence: standalone == base + overlay -----------------------------
def test_nvidia_standalone_equals_base_plus_overlay(base):
overlay = _load(NVIDIA_OVERLAY)
standalone = _load(NVIDIA_STANDALONE)
assert standalone == _merge_overlay_into_base(base, overlay)
def test_amd_standalone_equals_base_plus_overlay(base):
overlay = _load(AMD_OVERLAY)
standalone = _load(AMD_STANDALONE)
assert standalone == _merge_overlay_into_base(base, overlay)
# --- Non-odysseus services and volumes untouched ---------------------------
@pytest.mark.parametrize("standalone_path", [NVIDIA_STANDALONE, AMD_STANDALONE])
def test_non_odysseus_services_match_base(base, standalone_path):
standalone = _load(standalone_path)
for name, definition in base["services"].items():
if name == SERVICE:
continue
assert standalone["services"][name] == definition
assert set(standalone["services"]) == set(base["services"])
@pytest.mark.parametrize("standalone_path", [NVIDIA_STANDALONE, AMD_STANDALONE])
def test_top_level_volumes_match_base(base, standalone_path):
standalone = _load(standalone_path)
assert standalone.get("volumes") == base.get("volumes")
# --- odysseus = base service + only the overlay additions ------------------
def test_nvidia_odysseus_adds_only_overlay(base):
standalone = _load(NVIDIA_STANDALONE)
svc = standalone["services"][SERVICE]
base_svc = base["services"][SERVICE]
# Base environment preserved, plus exactly the two NVIDIA variables.
assert "NVIDIA_VISIBLE_DEVICES=all" in svc["environment"]
assert "NVIDIA_DRIVER_CAPABILITIES=compute,utility" in svc["environment"]
added_env = set(svc["environment"]) - set(base_svc["environment"])
assert added_env == {
"NVIDIA_VISIBLE_DEVICES=all",
"NVIDIA_DRIVER_CAPABILITIES=compute,utility",
}
# deploy block is new and matches the overlay's GPU reservation exactly.
assert "deploy" not in base_svc
devices = svc["deploy"]["resources"]["reservations"]["devices"]
assert devices == [
{"driver": "nvidia", "count": "all", "capabilities": ["gpu"]}
]
# No AMD-only keys leaked in.
assert "devices" not in svc
assert "group_add" not in svc
def test_amd_odysseus_adds_only_overlay(base):
standalone = _load(AMD_STANDALONE)
svc = standalone["services"][SERVICE]
base_svc = base["services"][SERVICE]
# Environment is unchanged from base for AMD.
assert svc["environment"] == base_svc["environment"]
# devices and group_add are new and match the overlay exactly.
assert "devices" not in base_svc
assert "group_add" not in base_svc
assert svc["devices"] == ["/dev/kfd", "/dev/dri"]
assert svc["group_add"] == ["video", "${RENDER_GID:-render}"]
# No NVIDIA-only keys leaked in.
assert "deploy" not in svc
+34
View File
@@ -66,3 +66,37 @@ def test_normal_model_payload_keeps_temperature(monkeypatch):
payload = _capture_openai_payload(monkeypatch, "gpt-4o", 0.2) payload = _capture_openai_payload(monkeypatch, "gpt-4o", 0.2)
assert payload["temperature"] == 0.2 assert payload["temperature"] == 0.2
assert payload["max_tokens"] == 5 assert payload["max_tokens"] == 5
def test_normal_model_payload_keeps_temperature_above_one(monkeypatch):
# OpenAI/local providers may validly use temperatures above 1.0; the clamp
# is Anthropic-only and must not touch this path.
payload = _capture_openai_payload(monkeypatch, "gpt-4o", 1.2)
assert payload["temperature"] == 1.2
def _anthropic_payload(temperature):
return llm_core._build_anthropic_payload(
"claude-3-5-sonnet",
[{"role": "user", "content": "Hi"}],
temperature,
max_tokens=5,
)
def test_anthropic_payload_clamps_above_one():
# Anthropic rejects temperature > 1.0 (e.g. the Nietzsche preset's 1.2).
assert _anthropic_payload(1.2)["temperature"] == 1.0
def test_anthropic_payload_keeps_in_range():
assert _anthropic_payload(0.7)["temperature"] == 0.7
def test_anthropic_payload_clamps_negative():
assert _anthropic_payload(-0.5)["temperature"] == 0.0
def test_anthropic_payload_none_temperature_does_not_crash():
payload = _anthropic_payload(None)
assert payload["temperature"] is None
+344
View File
@@ -1,6 +1,9 @@
"""Tests for model route helper functions — pure logic, no server needed.""" """Tests for model route helper functions — pure logic, no server needed."""
import asyncio
import json
import sys import sys
import types import types
from types import SimpleNamespace
from unittest.mock import MagicMock from unittest.mock import MagicMock
import httpx import httpx
@@ -29,6 +32,8 @@ import src.endpoint_resolver as endpoint_resolver
from routes.model_routes import ( from routes.model_routes import (
_match_provider_curated, _match_provider_curated,
_curate_models, _curate_models,
_visible_models,
_normalize_model_ids,
_is_chat_model, _is_chat_model,
_classify_endpoint, _classify_endpoint,
_probe_endpoint, _probe_endpoint,
@@ -470,3 +475,342 @@ class TestDockerHostGatewayReachable:
monkeypatch.setattr(model_routes.socket, "getaddrinfo", _fail) monkeypatch.setattr(model_routes.socket, "getaddrinfo", _fail)
assert model_routes._docker_host_gateway_reachable() is False assert model_routes._docker_host_gateway_reachable() is False
# ── pinned model IDs: normalization helper ──
class TestNormalizeModelIds:
def test_list_passthrough_trims_and_dedupes(self):
assert _normalize_model_ids([" a ", "a", "b", ""]) == ["a", "b"]
def test_json_string_list(self):
assert _normalize_model_ids('["x", "y", "x"]') == ["x", "y"]
def test_comma_and_newline_string(self):
assert _normalize_model_ids("a, b\n c ,a") == ["a", "b", "c"]
def test_none_and_empty(self):
assert _normalize_model_ids(None) == []
assert _normalize_model_ids("") == []
assert _normalize_model_ids(" ") == []
def test_non_string_values_ignored(self):
assert _normalize_model_ids([1, "ok", None, {"a": 1}]) == ["ok"]
# ── pinned model IDs: _visible_models merge ──
class TestVisibleModelsPinned:
def test_includes_pinned_not_in_cached(self):
visible = _visible_models(["a"], None, ["deploy-1"])
assert visible == ["a", "deploy-1"]
def test_cached_plus_pinned_dedup_preserves_order(self):
visible = _visible_models(["a", "b"], None, ["b", "c"])
assert visible == ["a", "b", "c"]
def test_hidden_can_hide_a_pinned_model(self):
visible = _visible_models(["a"], ["deploy-1"], ["deploy-1"])
assert visible == ["a"]
def test_accepts_json_string_inputs(self):
visible = _visible_models('["a"]', '["a"]', '["b"]')
assert visible == ["b"]
# ── pinned model IDs: route behaviour ──
# Building the router exercises FastAPI's Form() routes, which require
# python-multipart. The test env ships without it, so register a minimal stub
# (mirrors tests/test_review_regressions.py) only when it's genuinely missing.
if "python_multipart" not in sys.modules:
try:
import python_multipart # noqa: F401
except ImportError:
_mp_stub = types.ModuleType("python_multipart")
_mp_stub.__version__ = "0.0.13"
sys.modules["python_multipart"] = _mp_stub
class _PinnedFakeQuery:
def __init__(self, rows):
self.rows = list(rows)
def filter(self, *conditions):
return self
def order_by(self, *args):
return self
def first(self):
return self.rows[0] if self.rows else None
def all(self):
return list(self.rows)
class _PinnedFakeDb:
def __init__(self, rows):
self.rows = rows
self.added = []
self.committed = 0
def query(self, model):
return _PinnedFakeQuery(self.rows)
def add(self, row):
self.added.append(row)
def commit(self):
self.committed += 1
def close(self):
pass
class _FakeCol:
"""Column stand-in: every comparison/operator just returns itself so the
dedupe query expressions evaluate without a real SQLAlchemy column."""
__hash__ = None
def __eq__(self, other):
return self
def is_(self, other):
return self
def __or__(self, other):
return self
def desc(self):
return self
class _RecordingEndpoint:
"""ModelEndpoint stand-in that stores constructor kwargs as attributes.
Class-level fake columns let it double as the query class in the dedupe
lookup; instance attributes (set in __init__) shadow them per-row.
"""
id = _FakeCol()
base_url = _FakeCol()
owner = _FakeCol()
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
class _PinnedFakeRequest:
def __init__(self, body=None, headers=None):
self._body = body if body is not None else {}
self.headers = headers or {}
async def json(self):
return self._body
def _get_route(path, method):
from routes.model_routes import setup_model_routes
router = setup_model_routes(model_discovery=None)
for route in router.routes:
if getattr(route, "path", "") == path and method in getattr(route, "methods", set()):
return route.endpoint
raise AssertionError(f"{method} {path} not found")
def _make_endpoint(**kwargs):
base = dict(
id="ep1",
name="EP",
base_url="http://localhost:9999/v1",
api_key=None,
is_enabled=True,
hidden_models=None,
cached_models=None,
pinned_models=None,
model_type="llm",
supports_tools=None,
)
base.update(kwargs)
return SimpleNamespace(**base)
def test_patch_models_saves_pinned_models(monkeypatch):
ep = _make_endpoint()
db = _PinnedFakeDb([ep])
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
endpoint = _get_route("/api/model-endpoints/{ep_id}/models", "PATCH")
request = _PinnedFakeRequest(body={"pinned_models": ["deploy-1", "deploy-1", "deploy-2"]})
result = asyncio.run(endpoint("ep1", request))
assert json.loads(ep.pinned_models) == ["deploy-1", "deploy-2"]
assert result["pinned_count"] == 2
def test_patch_models_pinned_does_not_clobber_hidden(monkeypatch):
ep = _make_endpoint(hidden_models=json.dumps(["hide-me"]))
db = _PinnedFakeDb([ep])
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
endpoint = _get_route("/api/model-endpoints/{ep_id}/models", "PATCH")
request = _PinnedFakeRequest(body={"pinned_models": ["deploy-1"]})
asyncio.run(endpoint("ep1", request))
assert json.loads(ep.hidden_models) == ["hide-me"]
assert json.loads(ep.pinned_models) == ["deploy-1"]
def test_get_models_returns_pinned_when_probe_empty(monkeypatch):
ep = _make_endpoint(pinned_models=json.dumps(["deploy-1"]))
db = _PinnedFakeDb([ep])
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
monkeypatch.setattr(model_routes, "_probe_endpoint", lambda *a, **k: [])
endpoint = _get_route("/api/model-endpoints/{ep_id}/models", "GET")
result = endpoint("ep1", _PinnedFakeRequest())
ids = [row["id"] for row in result]
assert ids == ["deploy-1"]
assert result[0]["is_pinned"] is True
def test_reprobe_preserves_pinned_models(monkeypatch):
ep = _make_endpoint(pinned_models=json.dumps(["deploy-1"]))
db = _PinnedFakeDb([ep])
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
monkeypatch.setattr(model_routes, "_probe_endpoint", lambda *a, **k: ["m1"])
monkeypatch.setattr(model_routes, "_is_chat_model", lambda m: True)
monkeypatch.setattr(
model_routes, "_probe_single_model", lambda *a, **k: {"status": "ok"}
)
endpoint = _get_route("/api/model-endpoints/{ep_id}/probe", "GET")
response = endpoint("ep1", _PinnedFakeRequest())
async def _drain():
async for _ in response.body_iterator:
pass
asyncio.run(_drain())
# Probe rewrites cached/hidden but must never touch admin-pinned IDs.
assert json.loads(ep.pinned_models) == ["deploy-1"]
assert json.loads(ep.cached_models) == ["m1"]
def test_visible_models_handles_malformed_strings():
# Non-JSON cached/pinned strings are treated as comma/newline lists and
# never raise; a malformed hidden string is normalized too.
result = _visible_models("a,b", "b", "{bad json")
assert isinstance(result, list)
assert result == ["a", "{bad json"]
assert _visible_models("", None, "") == []
assert _visible_models("only-cached", None, None) == ["only-cached"]
def _create_form_kwargs(**overrides):
"""Defaults for every Form() param create_model_endpoint reads directly.
Calling the route as a plain function bypasses FastAPI form parsing, so the
Form() sentinels must be replaced with real strings.
"""
kwargs = dict(
name="",
api_key="",
skip_probe="true", # avoid any network probe in unit tests
require_models="false",
model_type="llm",
supports_tools="",
pinned_models="",
container_local="false",
shared="true",
)
kwargs.update(overrides)
return kwargs
def _patch_create_deps(monkeypatch, db):
import src.auth_helpers as auth_helpers
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
monkeypatch.setattr(model_routes, "ModelEndpoint", _RecordingEndpoint)
monkeypatch.setattr(model_routes, "_normalize_base", lambda b: b)
monkeypatch.setattr(model_routes, "_rewrite_loopback_for_docker", lambda b, **k: b)
monkeypatch.setattr(model_routes, "_load_settings", lambda: {"default_endpoint_id": "exists"})
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda u: u)
monkeypatch.setattr(auth_helpers, "get_current_user", lambda req: None)
def test_post_creates_endpoint_with_pinned_models(monkeypatch):
db = _PinnedFakeDb([]) # no existing row → fresh create path
_patch_create_deps(monkeypatch, db)
create = _get_route("/api/model-endpoints", "POST")
result = create(
_PinnedFakeRequest(),
base_url="http://host:1234/v1",
**_create_form_kwargs(pinned_models="deploy-1, deploy-1\ndeploy-2"),
)
assert result["pinned_models"] == ["deploy-1", "deploy-2"]
assert result["models"] == ["deploy-1", "deploy-2"]
assert result["online"] is True
# Persisted onto the created row.
assert len(db.added) == 1
assert json.loads(db.added[0].pinned_models) == ["deploy-1", "deploy-2"]
def test_post_dedupe_existing_merges_and_returns_pinned(monkeypatch):
existing = _make_endpoint(
cached_models=json.dumps(["m1"]),
hidden_models=None,
pinned_models=json.dumps(["old-pin"]),
)
db = _PinnedFakeDb([existing])
_patch_create_deps(monkeypatch, db)
create = _get_route("/api/model-endpoints", "POST")
result = create(
_PinnedFakeRequest(),
base_url="http://host:1234/v1",
**_create_form_kwargs(pinned_models="new-pin"),
)
assert result["existing"] is True
# Incoming pin merged onto the existing pins (no clobber, order preserved).
assert json.loads(existing.pinned_models) == ["old-pin", "new-pin"]
assert result["pinned_models"] == ["old-pin", "new-pin"]
# models = cached + pinned - hidden, visible merged list.
assert result["models"] == ["m1", "old-pin", "new-pin"]
# No new row created on the dedupe path.
assert db.added == []
def test_post_dedupe_existing_does_not_clobber_pinned_when_omitted(monkeypatch):
existing = _make_endpoint(
cached_models=json.dumps(["m1"]),
pinned_models=json.dumps(["keep-me"]),
)
db = _PinnedFakeDb([existing])
_patch_create_deps(monkeypatch, db)
create = _get_route("/api/model-endpoints", "POST")
result = create(
_PinnedFakeRequest(),
base_url="http://host:1234/v1",
**_create_form_kwargs(), # pinned_models defaults to ""
)
assert json.loads(existing.pinned_models) == ["keep-me"]
assert result["pinned_models"] == ["keep-me"]
assert db.committed == 0 # nothing to persist
+22 -7
View File
@@ -247,10 +247,14 @@ class _Column:
def __eq__(self, value): def __eq__(self, value):
return _Predicate(lambda row: getattr(row, self.name) == value) return _Predicate(lambda row: getattr(row, self.name) == value)
def desc(self):
return self
class _ModelEndpoint: class _ModelEndpoint:
is_enabled = _Column("is_enabled") is_enabled = _Column("is_enabled")
owner = _Column("owner") owner = _Column("owner")
created_at = _Column("created_at")
class _Query: class _Query:
@@ -261,6 +265,9 @@ class _Query:
self._rows = [r for r in self._rows if all(p(r) for p in predicates)] self._rows = [r for r in self._rows if all(p(r) for p in predicates)]
return self return self
def order_by(self, *exprs):
return self
def first(self): def first(self):
return self._rows[0] if self._rows else None return self._rows[0] if self._rows else None
@@ -280,8 +287,10 @@ def _ep(name, owner, *, is_enabled=True):
def _select(rows, owner): def _select(rows, owner):
wh_mod = _import_webhook_helper() wh_mod = _import_webhook_helper()
sys.modules["core.database"].ModelEndpoint = _ModelEndpoint # _select_api_chat_fallback_endpoint uses the module-level ModelEndpoint
return wh_mod._first_enabled_endpoint(_DB(rows), owner) # (not a local import), so we patch the module attribute directly.
wh_mod.ModelEndpoint = _ModelEndpoint
return wh_mod._select_api_chat_fallback_endpoint(_DB(rows), owner)
def test_sync_chat_fallback_never_picks_another_owners_endpoint(): def test_sync_chat_fallback_never_picks_another_owners_endpoint():
@@ -310,9 +319,15 @@ def test_sync_chat_fallback_skips_disabled_owned_endpoint():
assert ep is not None and ep.name == "shared" assert ep is not None and ep.name == "shared"
def test_sync_chat_fallback_null_owner_is_legacy_single_user_noop(): def test_sync_chat_fallback_null_owner_uses_shared_rows_only():
# An unresolvable/empty token owner keeps the original single-user behaviour # When no token owner is known, only null-owner (shared) endpoints are
# (owner_filter no-op): first enabled row, whatever it is. # visible — private endpoints of any user must not be returned.
rows = [_ep("first", "bob"), _ep("second", "alice")] rows = [_ep("bob-private", "bob"), _ep("shared", None)]
ep = _select(rows, None) ep = _select(rows, None)
assert ep is not None and ep.name == "first" assert ep is not None and ep.name == "shared"
def test_sync_chat_fallback_null_owner_returns_none_with_no_shared():
# No shared rows → fail closed rather than returning another user's endpoint.
rows = [_ep("bob-private", "bob"), _ep("alice-private", "alice")]
assert _select(rows, None) is None
-16
View File
@@ -1,16 +0,0 @@
from pathlib import Path
CSS = Path("static/style.css").read_text(encoding="utf-8")
UI_JS = Path("static/js/ui.js").read_text(encoding="utf-8")
def test_prompt_bar_exposes_desktop_resize_handle():
assert "resize: vertical;" in CSS
assert "max-height: min(60vh, 600px);" in CSS
def test_auto_resize_preserves_a_manually_chosen_height():
assert "textarea._manualResizeHeight = height;" in UI_JS
assert "const manualHeight = textarea._manualResizeHeight || 0;" in UI_JS
assert "const maxHeight = Math.max(autoMaxHeight, manualHeight);" in UI_JS
+1 -1
View File
@@ -1,4 +1,4 @@
from src.search.ranking import rank_search_results from services.search.ranking import rank_search_results
def test_news_queries_prefer_news_sources_over_sports_and_social_results(): def test_news_queries_prefer_news_sources_over_sports_and_social_results():
+36 -1
View File
@@ -8,7 +8,8 @@ module-level, time-injectable function.
from datetime import datetime, timezone from datetime import datetime, timezone
from src.search.ranking import recency_score, _utcnow_naive import services.search.ranking as live_ranking
from services.search.ranking import recency_score, _utcnow_naive, rank_search_results
def test_fresh_result_scores_one(): def test_fresh_result_scores_one():
@@ -37,3 +38,37 @@ def test_default_now_is_naive_utc():
assert now.tzinfo is None assert now.tzinfo is None
reference = datetime.now(timezone.utc).replace(tzinfo=None) reference = datetime.now(timezone.utc).replace(tzinfo=None)
assert abs((now - reference).total_seconds()) < 5 assert abs((now - reference).total_seconds()) < 5
def test_supported_timestamp_formats_parse():
# All three formats the current implementation supports resolve to the same
# ~4-day-old age, so each scores a full 1.0.
now = datetime(2026, 1, 5, 12, 0, 0)
assert recency_score("2026-01-01", now=now) == 1.0
assert recency_score("2026-01-01T08:30:00", now=now) == 1.0
assert recency_score("2026-01-01 08:30:00", now=now) == 1.0
def test_shim_reexports_live_objects():
# src.search.ranking is a compatibility shim; it must expose the *same*
# objects as the live services module so the two cannot diverge.
import src.search.ranking as shim
assert shim.recency_score is live_ranking.recency_score
assert shim.rank_search_results is live_ranking.rank_search_results
assert shim._utcnow_naive is live_ranking._utcnow_naive
def test_live_rank_path_prefers_newer_result(monkeypatch):
# Pin "now" so age scoring is deterministic. The two results are identical
# apart from age, isolating recency as the only differentiator.
monkeypatch.setattr(live_ranking, "_utcnow_naive", lambda: datetime(2026, 1, 31))
results = [
{"title": "Report", "url": "https://example.org/a", "snippet": "x", "age": "2026-01-01"},
{"title": "Report", "url": "https://example.org/b", "snippet": "x", "age": "2026-01-29"},
]
ranked = rank_search_results("report", results)
assert ranked[0]["url"] == "https://example.org/b"
assert ranked[1]["url"] == "https://example.org/a"