mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-06 05:02:24 +01:00
Compare commits
16
Commits
41a928f21b
...
7c7ac1021a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c7ac1021a | ||
|
|
b5590fd008 | ||
|
|
67b63e9844 | ||
|
|
1f6c5ac66b | ||
|
|
6861c41580 | ||
|
|
cc8fe2f6e3 | ||
|
|
8161c1253d | ||
|
|
8c2705b42a | ||
|
|
b1a4ed13b0 | ||
|
|
145f4fd2b4 | ||
|
|
1284b14a13 | ||
|
|
a75dd4a231 | ||
|
|
0deeba58ba | ||
|
|
562bc4dedc | ||
|
|
3706d756f3 | ||
|
|
eb79b76432 |
@@ -31,6 +31,7 @@ body:
|
||||
attributes:
|
||||
label: Install Method
|
||||
options:
|
||||
- "-- Please Select --"
|
||||
- Docker (docker compose up)
|
||||
- Manual Python install (pip / venv)
|
||||
- Windows native (launch-windows.ps1)
|
||||
@@ -44,6 +45,7 @@ body:
|
||||
attributes:
|
||||
label: Operating System
|
||||
options:
|
||||
- "-- Please Select --"
|
||||
- Linux
|
||||
- macOS
|
||||
- Windows
|
||||
|
||||
@@ -32,6 +32,7 @@ body:
|
||||
label: Area
|
||||
description: Which part of the application does this affect?
|
||||
options:
|
||||
- "-- Please Select --"
|
||||
- Chat / Agent
|
||||
- Email
|
||||
- Calendar
|
||||
@@ -83,6 +84,7 @@ body:
|
||||
attributes:
|
||||
label: Are you willing to implement this?
|
||||
options:
|
||||
- "-- Please Select --"
|
||||
- "Yes — I can open a PR"
|
||||
- "Partially — I can help but need guidance"
|
||||
- "No — I am only filing the request"
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
};
|
||||
@@ -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})
|
||||
@@ -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.
|
||||
|
||||
**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:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -334,6 +334,7 @@ class ModelEndpoint(TimestampMixin, Base):
|
||||
is_enabled = Column(Boolean, default=True)
|
||||
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)
|
||||
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"
|
||||
# Whether models on this endpoint accept OpenAI-style function
|
||||
# schemas + emit `tool_calls`. Auto-detected at Cookbook auto-
|
||||
@@ -856,6 +857,24 @@ def _migrate_add_cached_models_column():
|
||||
except Exception as 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():
|
||||
"""Add sort_order, image_url, repeat columns to notes if they don't exist."""
|
||||
import sqlite3
|
||||
@@ -1511,6 +1530,7 @@ def init_db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_migrate_add_hidden_models_column()
|
||||
_migrate_add_cached_models_column()
|
||||
_migrate_add_pinned_models_column()
|
||||
_migrate_add_notes_sort_order()
|
||||
_migrate_add_model_type_column()
|
||||
_migrate_add_model_endpoint_owner_column()
|
||||
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -1769,6 +1769,33 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
|
||||
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 []
|
||||
# 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")}
|
||||
import time as _t
|
||||
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:
|
||||
continue
|
||||
# Skip if no size info — without a size we can't tell if it's a real
|
||||
# full-weight model or a tiny adapter, so we'd rather drop it
|
||||
if est_vram is None:
|
||||
continue
|
||||
# Unknown-size models (e.g. MiniMax-M2.7, DeepSeek-V4-Flash) have no
|
||||
# "NB" in the repo id, so the regex above can't extract their
|
||||
# param count. Previously we dropped them entirely, which made
|
||||
# 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({
|
||||
"repo_id": repo_id,
|
||||
|
||||
@@ -108,7 +108,7 @@ def setup_hwfit_routes():
|
||||
return detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh)
|
||||
|
||||
@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.
|
||||
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
|
||||
@@ -196,7 +196,7 @@ def setup_hwfit_routes():
|
||||
if target_context is not None:
|
||||
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}
|
||||
|
||||
@router.get("/profiles")
|
||||
|
||||
+129
-25
@@ -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."
|
||||
|
||||
|
||||
def _visible_models(cached_models, hidden_models):
|
||||
"""Filter cached model IDs by hidden_models. Returns list of visible IDs."""
|
||||
all_models = json.loads(cached_models) if isinstance(cached_models, str) else (cached_models or [])
|
||||
def _normalize_model_ids(value):
|
||||
"""Coerce a model-ID input into a clean, ordered list of strings.
|
||||
|
||||
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:
|
||||
return all_models
|
||||
hidden = set(json.loads(hidden_models) if isinstance(hidden_models, str) else (hidden_models or []))
|
||||
return [m for m in all_models if m not in hidden]
|
||||
return merged
|
||||
hidden = set(_normalize_model_ids(hidden_models))
|
||||
return [m for m in merged if m not in hidden]
|
||||
|
||||
|
||||
def setup_model_routes(model_discovery):
|
||||
@@ -1123,10 +1178,13 @@ def setup_model_routes(model_discovery):
|
||||
hidden = set(json.loads(r.hidden_models))
|
||||
except Exception:
|
||||
pass
|
||||
visible = [m for m in all_models if m not in hidden]
|
||||
status = "online" if all_models else "offline"
|
||||
pinned = _normalize_model_ids(getattr(r, "pinned_models", None))
|
||||
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
|
||||
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)
|
||||
if ping.get("reachable"):
|
||||
status = "empty"
|
||||
@@ -1137,6 +1195,7 @@ def setup_model_routes(model_discovery):
|
||||
"has_key": bool(r.api_key),
|
||||
"is_enabled": r.is_enabled,
|
||||
"models": visible,
|
||||
"pinned_models": pinned,
|
||||
"hidden_count": len(hidden),
|
||||
"online": status != "offline",
|
||||
"status": status,
|
||||
@@ -1158,6 +1217,7 @@ def setup_model_routes(model_discovery):
|
||||
require_models: str = Form("false"),
|
||||
model_type: str = Form("llm"),
|
||||
supports_tools: str = Form(""), # "true"/"false"/"" (unknown)
|
||||
pinned_models: str = Form(""), # admin-pinned IDs: list/JSON/comma/newline
|
||||
container_local: str = Form("false"),
|
||||
# Default `shared=true` → endpoints are visible to all users (the
|
||||
# app's historical behaviour). Admins can pass `shared=false` to
|
||||
@@ -1199,11 +1259,28 @@ def setup_model_routes(model_discovery):
|
||||
.first()
|
||||
)
|
||||
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 {
|
||||
"id": existing.id,
|
||||
"name": existing.name,
|
||||
"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,
|
||||
"status": "online",
|
||||
"existing": True,
|
||||
@@ -1225,6 +1302,7 @@ def setup_model_routes(model_discovery):
|
||||
try:
|
||||
_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)
|
||||
_pinned = _normalize_model_ids(pinned_models)
|
||||
# Stamp owner so the picker only shows this endpoint to the admin
|
||||
# who added it. Pass `shared=true` to mark it null-owner (visible
|
||||
# to all users), preserving the pre-fix "everyone sees everything"
|
||||
@@ -1240,6 +1318,7 @@ def setup_model_routes(model_discovery):
|
||||
is_enabled=True,
|
||||
model_type=model_type.strip() if model_type else "llm",
|
||||
cached_models=json.dumps(model_ids) if model_ids else None,
|
||||
pinned_models=json.dumps(_pinned) if _pinned else None,
|
||||
supports_tools=_st,
|
||||
owner=_owner_val,
|
||||
)
|
||||
@@ -1265,9 +1344,10 @@ def setup_model_routes(model_discovery):
|
||||
"id": ep_id,
|
||||
"name": name.strip(),
|
||||
"base_url": base_url,
|
||||
"models": model_ids,
|
||||
"online": bool(model_ids) or bool(ping.get("reachable")),
|
||||
"status": "online" if model_ids else ("empty" if ping.get("reachable") else "offline"),
|
||||
"models": _merge_model_ids(model_ids, _pinned),
|
||||
"pinned_models": _pinned,
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -1360,7 +1440,8 @@ def setup_model_routes(model_discovery):
|
||||
hidden = set(json.loads(ep.hidden_models))
|
||||
except Exception:
|
||||
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)
|
||||
if 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)
|
||||
except Exception:
|
||||
pass
|
||||
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
|
||||
pinned_set = set(pinned)
|
||||
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:
|
||||
db.close()
|
||||
|
||||
@router.patch("/model-endpoints/{ep_id}/models")
|
||||
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)
|
||||
db = SessionLocal()
|
||||
@@ -1390,13 +1481,22 @@ def setup_model_routes(model_discovery):
|
||||
if not ep:
|
||||
raise HTTPException(404, "Endpoint not found")
|
||||
body = await request.json()
|
||||
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
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Body must be a JSON object")
|
||||
if "hidden" in body:
|
||||
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()
|
||||
_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:
|
||||
db.close()
|
||||
|
||||
@@ -1494,9 +1594,9 @@ def setup_model_routes(model_discovery):
|
||||
return {"endpoint_id": "", "endpoint_url": "", "model": ""}
|
||||
base = _normalize_base(ep.base_url)
|
||||
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:
|
||||
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:
|
||||
model = visible[0]
|
||||
except Exception:
|
||||
@@ -1532,6 +1632,9 @@ def setup_model_routes(model_discovery):
|
||||
ep.name = body["name"].strip() or ep.name
|
||||
if "model_type" in body and isinstance(body["model_type"], str):
|
||||
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
|
||||
# endpoint_url/model from every session referencing the old base
|
||||
# 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,
|
||||
"model_type": ep.model_type,
|
||||
"base_url": ep.base_url,
|
||||
"pinned_models": _normalize_model_ids(getattr(ep, "pinned_models", None)),
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
+26
-24
@@ -9,7 +9,9 @@ import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request, Form
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,23 +28,19 @@ MAX_MESSAGE_LEN = 32_000
|
||||
from core.middleware import require_admin as _require_admin
|
||||
|
||||
|
||||
def _first_enabled_endpoint(db, owner):
|
||||
"""First enabled ModelEndpoint VISIBLE to `owner` — their own rows plus
|
||||
legacy null-owner ("shared") rows. Owner-scoped on purpose: ModelEndpoint
|
||||
is per-user (core/database.py — "when non-null, the model picker only shows
|
||||
the endpoint to that user"), and the sync-chat fallback uses the row's
|
||||
decrypted `api_key`. An unscoped ``.first()`` would let a chat-scoped token
|
||||
(e.g. a paired mobile device) fall back onto ANOTHER user's private
|
||||
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.
|
||||
def _select_api_chat_fallback_endpoint(db, token_owner: Optional[str]):
|
||||
"""First enabled ModelEndpoint visible to token_owner — their own rows plus
|
||||
legacy null-owner ("shared") rows. Owner-scoped: an unscoped .first() would
|
||||
let a chat-scoped token fall back onto another user's private endpoint and
|
||||
silently spend that owner's API key/quota. Prefer owner rows before shared
|
||||
rows. Fails closed to null-owner rows only when token_owner is absent.
|
||||
Does not validate base_url — admin-configured local/LAN endpoints remain allowed.
|
||||
"""
|
||||
from core.database import ModelEndpoint
|
||||
from src.auth_helpers import owner_filter
|
||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
return q.first()
|
||||
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
|
||||
if token_owner:
|
||||
query = owner_filter(query, ModelEndpoint, token_owner)
|
||||
return query.order_by(ModelEndpoint.owner.desc(), ModelEndpoint.created_at).first()
|
||||
return query.filter(ModelEndpoint.owner == None).order_by(ModelEndpoint.created_at).first() # noqa: E711
|
||||
|
||||
|
||||
def _caller_owns_session(sess_owner, caller) -> bool:
|
||||
@@ -278,15 +276,21 @@ def setup_webhook_routes(
|
||||
api_key = body.api_key.strip()
|
||||
model = body.model or "deepseek-chat"
|
||||
|
||||
# Resolve base_url: explicit > provider name > model prefix auto-detect
|
||||
base_url = body.base_url.strip().rstrip("/") if body.base_url else None
|
||||
if not base_url:
|
||||
# Validate only token-supplied direct base_url; auto-resolved known-provider
|
||||
# URLs are not subject to extra local/LAN blocking beyond existing provider logic.
|
||||
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)
|
||||
if not base_url:
|
||||
raise HTTPException(400,
|
||||
"Could not auto-detect provider. Pass base_url (e.g. 'https://api.deepseek.com/v1') "
|
||||
"or provider ('deepseek', 'openai', 'groq', etc.)")
|
||||
|
||||
base_url = normalize_base(base_url)
|
||||
endpoint_url = build_chat_url(base_url)
|
||||
|
||||
@@ -306,9 +310,7 @@ def setup_webhook_routes(
|
||||
if not sess:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Owner-scoped: only THIS token owner's endpoints + legacy
|
||||
# shared rows, never another user's private endpoint/api_key.
|
||||
ep = _first_enabled_endpoint(db, token_owner)
|
||||
ep = _select_api_chat_fallback_endpoint(db, token_owner)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -5110,6 +5110,100 @@
|
||||
"release_date": "2023-10-29",
|
||||
"_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",
|
||||
"provider": "DeepSeek",
|
||||
@@ -12092,7 +12186,7 @@
|
||||
"min_ram_gb": 421.3,
|
||||
"recommended_ram_gb": 702.1,
|
||||
"min_vram_gb": 386.1,
|
||||
"quantization": "Q4_K_M",
|
||||
"quantization": "BF16",
|
||||
"context_length": 202752,
|
||||
"use_case": "General purpose text generation",
|
||||
"capabilities": [],
|
||||
@@ -12102,6 +12196,24 @@
|
||||
"hf_likes": 1698,
|
||||
"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",
|
||||
"provider": "moonshotai",
|
||||
@@ -13868,53 +13980,6 @@
|
||||
"gguf_sources": [],
|
||||
"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",
|
||||
"provider": "Google",
|
||||
@@ -19056,4 +19121,4 @@
|
||||
],
|
||||
"_discovered": true
|
||||
}
|
||||
]
|
||||
]
|
||||
+75
-14
@@ -362,10 +362,24 @@ def analyze_model(model, system, target_quant=None, scoring_use_case=None, targe
|
||||
elif target_quant:
|
||||
# User picked a specific 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:
|
||||
# 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"
|
||||
|
||||
# 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)
|
||||
|
||||
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 = {
|
||||
"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"],
|
||||
"vram": lambda r: r["required_gb"],
|
||||
"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):
|
||||
"""Rank all models against detected hardware. Returns sorted list of fit results."""
|
||||
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.
|
||||
|
||||
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()
|
||||
results = []
|
||||
|
||||
@@ -510,7 +564,7 @@ def rank_models(system, use_case=None, limit=50, search=None, sort="score", quan
|
||||
})
|
||||
if use_case == "image_gen":
|
||||
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]
|
||||
|
||||
# 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)
|
||||
|
||||
# Pick the visible SET by best fit (score) first, so it stays the same no
|
||||
# matter which column the user sorts by — otherwise sorting by params would
|
||||
# truncate to the N biggest models (huge ones that don't even fit) while
|
||||
# sorting by vram showed the N smallest. Only AFTER choosing the set do we
|
||||
# order it by the requested column.
|
||||
results.sort(key=SORT_KEYS["score"], reverse=True)
|
||||
results = results[:limit]
|
||||
# Pick the visible SET by the REQUESTED column. Per-user feedback: sorting
|
||||
# by Param should show the highest-param models PERIOD, not just those that
|
||||
# already fit. Same for every other column. Models that don't fit are still
|
||||
# in the list with their fit_level marking the constraint, so the user can
|
||||
# see the truth instead of a quietly-truncated view. Score sort is unchanged
|
||||
# (it's the default ranking and naturally pushes non-fits to the bottom).
|
||||
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"])
|
||||
# vram ascending (smallest first), everything else descending (biggest first)
|
||||
results.sort(key=sort_fn, reverse=(sort != "vram"))
|
||||
# Always sort descending then truncate top-N so each column shows the
|
||||
# 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
|
||||
|
||||
@@ -5,7 +5,9 @@ import shutil
|
||||
import subprocess
|
||||
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=...)
|
||||
|
||||
@@ -13,6 +13,13 @@ QUANT_BPP = {
|
||||
"AWQ-4bit": 0.50, "AWQ-8bit": 1.0,
|
||||
"GPTQ-Int4": 0.50, "GPTQ-Int8": 1.0,
|
||||
"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 = {
|
||||
@@ -24,6 +31,8 @@ QUANT_SPEED_MULT = {
|
||||
"AWQ-4bit": 1.2, "AWQ-8bit": 0.85,
|
||||
"GPTQ-Int4": 1.2, "GPTQ-Int8": 0.85,
|
||||
"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 = {
|
||||
@@ -32,9 +41,18 @@ QUANT_QUALITY_PENALTY = {
|
||||
"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,
|
||||
"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,
|
||||
"GPTQ-Int4": -3.0, "GPTQ-Int8": 0.0,
|
||||
"mlx-4bit": -4.0, "mlx-8bit": 0.0, "mlx-6bit": -1.0,
|
||||
# Bare "AWQ" and "AWQ-8bit" used to be 0.0 (tied with FP8). In practice
|
||||
# AWQ-anything is a calibrated reconstruction, not raw 8-bit weights —
|
||||
# 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 = {
|
||||
@@ -46,6 +64,8 @@ QUANT_BYTES_PER_PARAM = {
|
||||
"AWQ-4bit": 0.5, "AWQ-8bit": 1.0,
|
||||
"GPTQ-Int4": 0.5, "GPTQ-Int8": 1.0,
|
||||
"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.
|
||||
@@ -53,6 +73,7 @@ QUANT_BYTES_PER_PARAM = {
|
||||
PREQUANTIZED_PREFIXES = (
|
||||
"AWQ-", "GPTQ-", "mlx-", "FP8", "FP4", "NVFP4", "MXFP4", "NF4",
|
||||
"INT4", "INT8", "W4A16", "W8A8", "W8A16",
|
||||
"FP4-MoE-Mixed", "FP8-Mixed",
|
||||
)
|
||||
|
||||
|
||||
|
||||
+38
-19
@@ -2,12 +2,49 @@
|
||||
|
||||
import re
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
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"}
|
||||
_SPORTS_HINTS = {
|
||||
"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.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:
|
||||
if not is_news_query:
|
||||
return 0.0
|
||||
|
||||
+10
-148
@@ -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
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
from urllib.parse import urlparse
|
||||
The real implementation lives in :mod:`services.search.ranking`, which is what
|
||||
the search runtime (services/search/core.py) imports. This module used to hold a
|
||||
parallel copy; it now re-exports so the two cannot drift out of sync again.
|
||||
"""
|
||||
|
||||
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"}
|
||||
_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"
|
||||
from services.search.ranking import ( # noqa: F401
|
||||
_AGE_FORMATS,
|
||||
_utcnow_naive,
|
||||
rank_search_results,
|
||||
recency_score,
|
||||
)
|
||||
_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]
|
||||
|
||||
@@ -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
@@ -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"/>
|
||||
</svg>
|
||||
<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>
|
||||
</div>
|
||||
<div class="list-item" id="tool-research-btn">
|
||||
|
||||
@@ -23,6 +23,44 @@ import {
|
||||
// browser loads it once. See cookbook-hwfit.js.
|
||||
} from './cookbook.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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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';
|
||||
|
||||
// ── Error diagnosis ──
|
||||
@@ -120,17 +158,12 @@ export const ERROR_PATTERNS = [
|
||||
},
|
||||
{
|
||||
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: [
|
||||
{ label: 'Update vLLM on server', action: (panel) => {
|
||||
const taskEl = panel.closest('.cookbook-task');
|
||||
const task = taskEl ? _loadTasks().find(t => t.sessionId === taskEl.dataset.taskId) : null;
|
||||
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);
|
||||
}},
|
||||
{ label: 'Retry with TP=4', action: (panel) => _serveAutoRetryReplace(panel, '--tensor-parallel-size', '4') },
|
||||
{ label: 'Retry with TP=2', action: (panel) => _serveAutoRetryReplace(panel, '--tensor-parallel-size', '2') },
|
||||
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -528,53 +561,14 @@ export function _showDiagnosis(panel, diagnosis, sourceText) {
|
||||
? `Suggested action: ${fixes[0].label}.`
|
||||
: 'Suggested action: copy the error and adjust the serve settings.');
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'cookbook-diag-header';
|
||||
|
||||
const fold = document.createElement('button');
|
||||
fold.className = 'cookbook-diag-fold';
|
||||
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);
|
||||
// Simplified diagnosis card: just the error message + suggestion + fix
|
||||
// button(s). Removed the fold toggle, copy button, and × dismiss — they
|
||||
// made the card noisy without earning their keep. _diagCollapsed is kept
|
||||
// as a stub so callers don't have to change.
|
||||
panel._diagCollapsed = false;
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'cookbook-diag-body';
|
||||
body.classList.toggle('hidden', panel._diagCollapsed);
|
||||
fold.querySelector('.cookbook-diag-chevron').textContent = panel._diagCollapsed ? '▸' : '▾';
|
||||
const msg = document.createElement('div');
|
||||
msg.className = 'cookbook-diag-message';
|
||||
msg.textContent = diagnosis.message;
|
||||
@@ -583,12 +577,6 @@ export function _showDiagnosis(panel, diagnosis, sourceText) {
|
||||
suggestion.className = 'cookbook-diag-suggestion';
|
||||
suggestion.textContent = suggestionText;
|
||||
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);
|
||||
|
||||
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');
|
||||
btn.className = 'cookbook-btn cookbook-diag-btn';
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
runFix(fix, btn);
|
||||
@@ -653,7 +641,7 @@ export function _showDiagnosis(panel, diagnosis, sourceText) {
|
||||
for (const fix of fixes) {
|
||||
const item = document.createElement('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) => {
|
||||
e.stopPropagation();
|
||||
if (item.dataset.busy || trigger.dataset.busy) return;
|
||||
|
||||
+109
-11
@@ -153,14 +153,31 @@ export function _renderGpuToggles(system) {
|
||||
}
|
||||
const validCounts = _validTpCounts(poolSize);
|
||||
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>';
|
||||
const hasExplicitCount = typeof container._activeCount === 'number';
|
||||
for (const n of validCounts) {
|
||||
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>`;
|
||||
}
|
||||
// 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;
|
||||
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.
|
||||
const sel = container.querySelector('#hwfit-gpu-group');
|
||||
@@ -188,9 +205,12 @@ export function _renderGpuToggles(system) {
|
||||
} else {
|
||||
btn.classList.add('active');
|
||||
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');
|
||||
if (quantSel) {
|
||||
if (quantSel && quantSel.value !== '') {
|
||||
if (count <= 1) {
|
||||
quantSel.value = 'Q4_K_M'; // RAM or 1 GPU -> Q4 sweet spot
|
||||
} 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_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) {
|
||||
const n = Number(value) || 0;
|
||||
if (!n) return 'Max';
|
||||
@@ -449,7 +472,9 @@ export async function _hwfitFetch(fresh = false) {
|
||||
fetch(`/api/model/cached?${_cacheParams}`, { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.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
|
||||
list.querySelectorAll('.hwfit-row[data-model]').forEach(row => {
|
||||
const name = row.dataset.model;
|
||||
@@ -502,6 +527,9 @@ export async function _hwfitFetch(fresh = false) {
|
||||
if (useCase) params.set('use_case', useCase);
|
||||
if (quantPref) params.set('quant', quantPref);
|
||||
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 res = await fetch(endpoint);
|
||||
@@ -845,6 +873,13 @@ export function _hwfitRenderList(el, models) {
|
||||
const sortSel = document.getElementById('hwfit-sort');
|
||||
const currentSort = sortSel?.value || 'score';
|
||||
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">';
|
||||
for (const col of _hwfitColumns) {
|
||||
const sortable = col.key ? ' hwfit-sortable' : '';
|
||||
@@ -856,7 +891,16 @@ export function _hwfitRenderList(el, models) {
|
||||
arrow = isReversed ? ' \u25B2' : ' \u25BC';
|
||||
}
|
||||
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>';
|
||||
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>' : '';
|
||||
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-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-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-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>`;
|
||||
@@ -899,7 +965,26 @@ export function _hwfitRenderList(el, models) {
|
||||
});
|
||||
// Clickable header columns → sort (click again to toggle direction)
|
||||
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;
|
||||
if (!sortKey) return;
|
||||
const sel = document.getElementById('hwfit-sort');
|
||||
@@ -983,7 +1068,16 @@ export function _expandModelRow(row, modelData) {
|
||||
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>`;
|
||||
} 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>`;
|
||||
|
||||
@@ -1207,11 +1301,15 @@ export function _hwfitInit() {
|
||||
ctx.addEventListener('change', () => {
|
||||
const targetCtx = _ctxValue();
|
||||
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');
|
||||
if (sortSel) {
|
||||
if (targetCtx) {
|
||||
sortSel.value = 'fit';
|
||||
sortSel.dataset.reverse = '1';
|
||||
sortSel.value = 'vram';
|
||||
sortSel.dataset.reverse = '1'; // ascending = smallest VRAM first
|
||||
} else {
|
||||
sortSel.value = 'score';
|
||||
sortSel.dataset.reverse = '';
|
||||
|
||||
+110
-28
@@ -18,6 +18,7 @@ import {
|
||||
_launchServeTask, _serveAutoFix, _serveAutoRetry, _serveAutoRetryReplace, _serveAutoRetryRemove,
|
||||
_startBackgroundMonitor, _syncFromServer,
|
||||
_retryDownload, _nextAvailablePort, _processQueue,
|
||||
_selfHealStaleTasks,
|
||||
} from './cookbookRunning.js';
|
||||
|
||||
import {
|
||||
@@ -641,6 +642,13 @@ async function _fetchDependencies() {
|
||||
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 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')}">`
|
||||
+ `<div class="cookbook-dep-info">`
|
||||
+ `<div class="memory-item-title">${esc(pkg.name)}</div>`
|
||||
@@ -648,6 +656,7 @@ async function _fetchDependencies() {
|
||||
+ note
|
||||
+ updateNote
|
||||
+ `</div>`
|
||||
+ _rebuildBtn
|
||||
+ `<span class="cookbook-dep-tag cookbook-dep-cat">${esc(pkg.category)}</span>`
|
||||
+ _statusTag(pkg, isLocal, isSystemDep, winBlocked)
|
||||
+ `</div>`;
|
||||
@@ -1116,6 +1125,7 @@ function _wireTabEvents(body) {
|
||||
|
||||
document.getElementById('serve-bulk-cancel')?.addEventListener('click', () => {
|
||||
selectBtn.classList.remove('active');
|
||||
selectBtn.textContent = 'Select'; // reset label so the button doesn't stay reading "Cancel" after exit
|
||||
bulkBar.classList.add('hidden');
|
||||
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);
|
||||
}
|
||||
selectBtn.classList.remove('active');
|
||||
selectBtn.textContent = 'Select'; // same reset as bulk-cancel
|
||||
bulkBar.classList.add('hidden');
|
||||
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
|
||||
// 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 hfArrow = document.getElementById('cookbook-hf-latest-arrow');
|
||||
const hfList = document.getElementById('cookbook-hf-latest-list');
|
||||
@@ -1389,9 +1418,32 @@ function _wireTabEvents(body) {
|
||||
// HF token — save on change
|
||||
const hfInput = document.getElementById('hwfit-hftoken');
|
||||
if (hfInput) {
|
||||
hfInput.addEventListener('change', () => {
|
||||
_envState.hfToken = hfInput.value.trim();
|
||||
_persistEnvState();
|
||||
hfInput.addEventListener('change', async () => {
|
||||
const val = hfInput.value.trim();
|
||||
_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 += `${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 += `<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) {
|
||||
// New server: Cancel (discard) sits top-right; the default toggle only makes
|
||||
// sense once the server is saved.
|
||||
@@ -1491,12 +1543,15 @@ function _renderRecipes() {
|
||||
// Search group
|
||||
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 += '<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;">';
|
||||
html += '<h2 style="margin:0;padding:0;line-height:1;">Download</h2>';
|
||||
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>';
|
||||
html += '</button>';
|
||||
html += '<div id="cookbook-download-card-body" style="display:none;">';
|
||||
html += '<p class="memory-desc doclib-desc" style="margin-top:6px;">Download directly from Scan, or paste a HuggingFace model link.</p>';
|
||||
// Foldable Download admin-card: clicking the h2 header collapses the
|
||||
// entire card body (description + download input + HF latest section).
|
||||
// State persisted to localStorage so the fold survives reloads.
|
||||
const _dlTabFolded = (() => { try { return localStorage.getItem('cookbook_dl_tab_folded_v1') === '1'; } catch { return false; } })();
|
||||
html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;">';
|
||||
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">';
|
||||
|
||||
// Section 1: Settings
|
||||
@@ -1551,6 +1606,7 @@ function _renderRecipes() {
|
||||
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>`;
|
||||
html += `</div>`; // /#cookbook-dl-tab-fold-body (whole Download card body)
|
||||
|
||||
// Search section
|
||||
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 += '<div class="hwfit-toolbar" style="margin-top:9px;">';
|
||||
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>';
|
||||
// Image tab removed — text→image gen is gone from this build (only inpaint
|
||||
// remains, which uses its own settings panel). Vision (multimodal) stays.
|
||||
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;" />';
|
||||
// Quant (Q4/Q8/…) lives next to the search now.
|
||||
html += '<select class="cookbook-field-input hwfit-quant" id="hwfit-quant" style="height:28px;">';
|
||||
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>';
|
||||
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.
|
||||
// Engine sits next to the type filter so the "what category / which serving
|
||||
// path" filters live together; Quant + Context are storage-format and budget
|
||||
// levers, grouped to the right.
|
||||
html += '<span class="hwfit-engine-wrap">';
|
||||
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="llamacpp">llama.cpp</option>';
|
||||
html += '<option value="vllm">vLLM</option>';
|
||||
html += '<option value="sglang">SGLang</option>';
|
||||
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 += '<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>';
|
||||
// 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 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;">';
|
||||
@@ -1597,7 +1663,7 @@ function _renderRecipes() {
|
||||
html += '<div class="hwfit-gpu-toggles" id="hwfit-gpu-toggles"></div>';
|
||||
// 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 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 paradigm). Newest is reachable by clicking the Model column header.
|
||||
html += '<select class="cookbook-field-input hwfit-sort" id="hwfit-sort" style="display:none">';
|
||||
@@ -1617,6 +1683,16 @@ function _renderRecipes() {
|
||||
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 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>';
|
||||
|
||||
@@ -1661,7 +1737,8 @@ function _renderRecipes() {
|
||||
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 += '<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 += '<select class="cookbook-field-input" id="hwfit-deps-server" style="height:28px;min-width:70px;">';
|
||||
html += _buildServerOpts(false);
|
||||
@@ -1700,7 +1777,7 @@ function _renderRecipes() {
|
||||
|
||||
// ── Servers block ───────────────────────────────────────────────────
|
||||
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>';
|
||||
// Reuse the calendar +New pill: spinning plus, label fades in idea uses
|
||||
// the same `.cal-add-btn-text` rules, so styling stays consistent.
|
||||
@@ -1847,6 +1924,11 @@ export async function open(opts) {
|
||||
_rendered = true;
|
||||
_clearCookbookNotif();
|
||||
_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) {
|
||||
// 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
|
||||
|
||||
@@ -535,6 +535,42 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
|
||||
uiModule.showToast(`${shortName} is already ${duplicate.status === 'queued' ? 'queued' : 'downloading'}`);
|
||||
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);
|
||||
|
||||
if (activeOnHost) {
|
||||
|
||||
@@ -35,13 +35,34 @@ function _taskBadge(task) {
|
||||
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) {
|
||||
if (!task || task.status === 'running') 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);
|
||||
}
|
||||
|
||||
function _clearPillLabel(task) {
|
||||
if (_downloadOutputLooksActive(task)) return 'reconnect';
|
||||
return 'clear';
|
||||
}
|
||||
|
||||
@@ -1537,7 +1558,16 @@ export function _renderRunningTab() {
|
||||
|
||||
const tasks = _loadTasks();
|
||||
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>` : '';
|
||||
|
||||
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>
|
||||
<button class="cookbook-task-menu-btn" title="Actions">⋮</button>
|
||||
</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>
|
||||
`;
|
||||
|
||||
@@ -1824,9 +1854,31 @@ export function _renderRunningTab() {
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
const s = secs % 60;
|
||||
_uptimeEl.textContent = h > 0
|
||||
const _timer = h > 0
|
||||
? `${_prefix}: ${h}h ${String(m).padStart(2,'0')}m`
|
||||
: `${_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);
|
||||
}
|
||||
|
||||
@@ -1874,6 +1926,39 @@ export function _renderRunningTab() {
|
||||
if (_clearChk) {
|
||||
_clearChk.addEventListener('click', (e) => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -2517,12 +2602,37 @@ async function _reconnectTask(el, task) {
|
||||
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,
|
||||
// including ones already finished in a previous session (resume) —
|
||||
// so on a resumed download it reflects the true overall progress,
|
||||
// whereas completed/totalFiles only see this session's files (→ 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.
|
||||
let pct = _dlAgg;
|
||||
if (_fetchPct != null) pct = Math.max(pct, _fetchPct);
|
||||
@@ -2927,9 +3037,84 @@ function _refreshServerDots() {
|
||||
_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() {
|
||||
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();
|
||||
_checkServeReachability();
|
||||
}
|
||||
|
||||
@@ -560,6 +560,15 @@ function _rerenderCachedModels() {
|
||||
+ `</div>`;
|
||||
|
||||
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
|
||||
panelHtml += `<div class="hwfit-serve-row">`;
|
||||
const _backendChoices = _isWindows()
|
||||
@@ -595,13 +604,15 @@ function _rerenderCachedModels() {
|
||||
// Row 2: Core settings
|
||||
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>${_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 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 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 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>`;
|
||||
// Row 2b: Diffusers settings
|
||||
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);
|
||||
const _specOpts = _specMethods.map(m =>
|
||||
`<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>`;
|
||||
panelHtml += `</div>`;
|
||||
@@ -719,7 +730,7 @@ function _rerenderCachedModels() {
|
||||
// pushes Cancel + Launch to the right.
|
||||
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-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>`;
|
||||
|
||||
@@ -1655,6 +1666,37 @@ function _rerenderCachedModels() {
|
||||
});
|
||||
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
|
||||
// the root so per-model state doesn't leak between models.
|
||||
try {
|
||||
|
||||
+3
-19
@@ -519,20 +519,7 @@ export function getAutoScroll() {
|
||||
export function autoResize(textarea) {
|
||||
const lineHeight = parseInt(getComputedStyle(textarea).lineHeight);
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
const autoMaxHeight = 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);
|
||||
}
|
||||
const maxHeight = isMobile ? 150 : lineHeight * 8;
|
||||
|
||||
// Use a hidden clone to measure without disrupting the real textarea
|
||||
let clone = textarea._resizeClone;
|
||||
@@ -552,12 +539,9 @@ export function autoResize(textarea) {
|
||||
clone.style.width = textarea.offsetWidth + 'px';
|
||||
clone.value = textarea.value;
|
||||
clone.style.height = '0';
|
||||
const manualHeight = textarea._manualResizeHeight || 0;
|
||||
const maxHeight = Math.max(autoMaxHeight, manualHeight);
|
||||
const newHeight = Math.min(Math.max(clone.scrollHeight, lineHeight, manualHeight), maxHeight);
|
||||
textarea._autoResizeHeight = newHeight;
|
||||
const newHeight = Math.min(Math.max(clone.scrollHeight, lineHeight), maxHeight);
|
||||
textarea.style.height = newHeight + 'px';
|
||||
textarea.style.overflow = newHeight >= autoMaxHeight ? 'auto' : 'hidden';
|
||||
textarea.style.overflow = newHeight >= maxHeight ? 'auto' : 'hidden';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+219
-12
@@ -2204,12 +2204,12 @@ body.bg-pattern-sparkles {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
resize: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--fg);
|
||||
min-height: 24px;
|
||||
max-height: min(60vh, 600px);
|
||||
max-height: 200px;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
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);
|
||||
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 {
|
||||
background: color-mix(in srgb, var(--green, #50fa7b) 18%, transparent);
|
||||
color: var(--green, #50fa7b);
|
||||
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 {
|
||||
background: color-mix(in srgb, var(--fg) 8%, transparent);
|
||||
color: color-mix(in srgb, var(--fg) 60%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--fg) 16%, transparent);
|
||||
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 {
|
||||
background: var(--accent, var(--red));
|
||||
@@ -18648,12 +18673,30 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
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
|
||||
(Firefox renders <button> taller otherwise); height comes from .cookbook-dep-tag. */
|
||||
appearance: none;
|
||||
-webkit-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; }
|
||||
/* Installed split button: "Installed" label + separator + ▾ caret; clicking it
|
||||
opens the actions menu (Update). Replaces the old ⋮ button. */
|
||||
@@ -18696,6 +18739,94 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
display: block;
|
||||
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 {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
@@ -19245,9 +19376,12 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
position: relative;
|
||||
top: -4px;
|
||||
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-radius: 9px;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
@@ -19949,6 +20083,17 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
border-color: var(--color-error);
|
||||
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) ── */
|
||||
.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-quant { min-width: 50px; flex-shrink: 0; }
|
||||
.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 {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
@@ -20447,6 +20626,28 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
.hwfit-help-chip-inline {
|
||||
margin-left: -2px;
|
||||
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 {
|
||||
height: 28px;
|
||||
@@ -20460,21 +20661,27 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
border-radius: 4px;
|
||||
color: var(--fg-muted);
|
||||
background: var(--bg);
|
||||
font-size: 10px;
|
||||
font-size: 12px; /* match .cookbook-field-input — was 10px and read smaller than siblings */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.hwfit-ctx-control span {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
opacity: 0.75;
|
||||
/* Match Quant/Engine select label style: no uppercase, no letter-spacing. */
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.hwfit-ctx-control input[type="range"] {
|
||||
width: 54px;
|
||||
min-width: 54px;
|
||||
height: 16px;
|
||||
width: 64px;
|
||||
min-width: 64px;
|
||||
height: 4px;
|
||||
padding: 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));
|
||||
}
|
||||
.hwfit-ctx-control output {
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
@@ -66,3 +66,37 @@ def test_normal_model_payload_keeps_temperature(monkeypatch):
|
||||
payload = _capture_openai_payload(monkeypatch, "gpt-4o", 0.2)
|
||||
assert payload["temperature"] == 0.2
|
||||
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
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Tests for model route helper functions — pure logic, no server needed."""
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
@@ -29,6 +32,8 @@ import src.endpoint_resolver as endpoint_resolver
|
||||
from routes.model_routes import (
|
||||
_match_provider_curated,
|
||||
_curate_models,
|
||||
_visible_models,
|
||||
_normalize_model_ids,
|
||||
_is_chat_model,
|
||||
_classify_endpoint,
|
||||
_probe_endpoint,
|
||||
@@ -470,3 +475,342 @@ class TestDockerHostGatewayReachable:
|
||||
|
||||
monkeypatch.setattr(model_routes.socket, "getaddrinfo", _fail)
|
||||
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
|
||||
|
||||
@@ -247,10 +247,14 @@ class _Column:
|
||||
def __eq__(self, value):
|
||||
return _Predicate(lambda row: getattr(row, self.name) == value)
|
||||
|
||||
def desc(self):
|
||||
return self
|
||||
|
||||
|
||||
class _ModelEndpoint:
|
||||
is_enabled = _Column("is_enabled")
|
||||
owner = _Column("owner")
|
||||
created_at = _Column("created_at")
|
||||
|
||||
|
||||
class _Query:
|
||||
@@ -261,6 +265,9 @@ class _Query:
|
||||
self._rows = [r for r in self._rows if all(p(r) for p in predicates)]
|
||||
return self
|
||||
|
||||
def order_by(self, *exprs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
@@ -280,8 +287,10 @@ def _ep(name, owner, *, is_enabled=True):
|
||||
|
||||
def _select(rows, owner):
|
||||
wh_mod = _import_webhook_helper()
|
||||
sys.modules["core.database"].ModelEndpoint = _ModelEndpoint
|
||||
return wh_mod._first_enabled_endpoint(_DB(rows), owner)
|
||||
# _select_api_chat_fallback_endpoint uses the module-level ModelEndpoint
|
||||
# (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():
|
||||
@@ -310,9 +319,15 @@ def test_sync_chat_fallback_skips_disabled_owned_endpoint():
|
||||
assert ep is not None and ep.name == "shared"
|
||||
|
||||
|
||||
def test_sync_chat_fallback_null_owner_is_legacy_single_user_noop():
|
||||
# An unresolvable/empty token owner keeps the original single-user behaviour
|
||||
# (owner_filter no-op): first enabled row, whatever it is.
|
||||
rows = [_ep("first", "bob"), _ep("second", "alice")]
|
||||
def test_sync_chat_fallback_null_owner_uses_shared_rows_only():
|
||||
# When no token owner is known, only null-owner (shared) endpoints are
|
||||
# visible — private endpoints of any user must not be returned.
|
||||
rows = [_ep("bob-private", "bob"), _ep("shared", 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
|
||||
|
||||
@@ -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,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():
|
||||
|
||||
@@ -8,7 +8,8 @@ module-level, time-injectable function.
|
||||
|
||||
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():
|
||||
@@ -37,3 +38,37 @@ def test_default_now_is_naive_utc():
|
||||
assert now.tzinfo is None
|
||||
reference = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user