Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 29 additions & 14 deletions docs/features/qwen38-rtx3090.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,22 @@ appended, so your `GPU_UTIL`, `DFLASH_TOKENS` or your own `VLLM_API_KEY` survive
The API key it generates is written straight onto the two seeded providers, so
step 2 below is just "enable it".

Two things it deliberately will not do. On Windows it refuses unless
`VLLM_QWEN_PROJECT_DIR` is set (§1c): the default `~/qwen-serving` resolves to a
*Windows* home, and 20 GB of weights reached from the WSL2 VM across a 9p share is
a mistake that costs 20 GB to discover. And it never raises the WSL2 memory
ceiling — see §1a for why that one stays manual.
**On Windows it places the project inside WSL2 for you.** The default
`~/qwen-serving` resolves to a *Windows* home, and 20 GB of weights reached from
the WSL2 VM across a 9p share is a mistake that costs 20 GB to discover — so
before it clones anything, PortOS asks WSL for the default distro's name and home
(`wsl.exe -e sh -c 'echo "$WSL_DISTRO_NAME"; echo "$HOME"'`), checks that
`\\wsl.localhost\<distro>\home\<user>` is readable from Windows, and records
the result as `VLLM_QWEN_PROJECT_DIR` in PortOS's own `.env` so the readiness
check, the Start button and the next server boot all resolve the same directory.
It refuses only where it genuinely cannot answer the question — no WSL on the
host, no distro but a container engine's own (`docker-desktop` is recreated on a
reset), or a `\\wsl.localhost` share Windows cannot read — and each refusal
names that host's fix. Setting `VLLM_QWEN_PROJECT_DIR` yourself still overrides
the whole decision (§1c).

The one thing it deliberately will not do is raise the WSL2 memory ceiling — see
§1a for why that one stays manual.

#### By hand

Expand Down Expand Up @@ -227,19 +238,22 @@ On Windows, confirm the same URL answers from the PortOS side too — Docker
Desktop / WSL2 localhost forwarding is what makes a container in the VM
reachable at `127.0.0.1` on the host.

### 1c. Point PortOS at the project (Windows only)
### 1c. Point PortOS at the project (Windows, optional)

A native-Win32 PortOS resolves the default `~/qwen-serving` to a *Windows* home
directory, where the project is not. Set `VLLM_QWEN_PROJECT_DIR` to the distro's
UNC path so the readiness checklist can see the project and its `models/`:
directory, where the project is not. **You do not normally have to fix this
yourself** — the provisioning button and the Start button both detect the distro
and record the UNC path (see above). Set `VLLM_QWEN_PROJECT_DIR` only to overrule
that, or when you cloned by hand somewhere other than the default distro's home:

```
VLLM_QWEN_PROJECT_DIR=\\wsl.localhost\<distro>\home\<user>\qwen-serving
```

Node reads that path, and `docker compose` accepts it as a working directory, so
both the checklist and the Start button work from it. On Linux the default is
already correct.
both the checklist and the Start button work from it. An exported value wins over
anything PortOS detected on an earlier run. On Linux the default is already
correct.

**`DFLASH_TOKENS=15` is deliberately not a default.** It is the setting behind
the headline throughput number, but it costs KV cache: 56k context across 4
Expand Down Expand Up @@ -283,9 +297,10 @@ explicitly-named provisioning action above. `prepare` writes them into the proje
`models/` directory (compose bind-mounts `${MODELS_DIR:-./models}`), which is
what PortOS looks at; the `qwen-cache` docker volume alongside it holds only the
torch.compile / Triton / FlashInfer JIT caches. If PortOS cannot read that
directory at all — the normal case on Windows before `VLLM_QWEN_PROJECT_DIR` is
set to the UNC path above — the button says so, and `VLLM_QWEN_WEIGHTS_DIR` is
the escape hatch for weights kept somewhere else entirely.
directory at all, the button says so, and `VLLM_QWEN_WEIGHTS_DIR` is the escape
hatch for weights kept somewhere else entirely. On Windows the Start button first
resolves the WSL2 placement described above, so a project prepared by hand inside
the distro is found without any configuration.

## What the numbers mean

Expand Down Expand Up @@ -319,7 +334,7 @@ concurrent server result.

| Variable | Default | Purpose |
|---|---|---|
| `VLLM_QWEN_PROJECT_DIR` | `~/qwen-serving` | Where the compose project was cloned. Required on Windows, as the `\\wsl.localhost\…` UNC path. |
| `VLLM_QWEN_PROJECT_DIR` | `~/qwen-serving`, or the `\\wsl.localhost\…` path PortOS detected and recorded in its own `.env` on Windows | Where the compose project was cloned. Set it only to overrule the detected placement — an exported value wins over the record. |
| `VLLM_QWEN_WEIGHTS_DIR` | *(unset)* | The directory holding the model weights, when it is not the project's own `models/` — e.g. a `MODELS_DIR` pointed elsewhere, or a HuggingFace hub cache shared with another stack. |

## Related
Expand Down
5 changes: 3 additions & 2 deletions server/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,10 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
| `localProviderRuntime.js` | Which LOCAL daemon a provider talks to, and where — `LOCAL_RUNTIMES` (llama.cpp / Ollama / LM Studio / MTPLX / vLLM: label, binary, canonical base URL read from `opencodeConfig.js` rather than re-typed, manage/docs links, model-download hint), `localBackendForProvider` + `localEndpointPort` + `isLocalInstanceHost` (moved here from `services/localModelHealing.js`, which re-exports them, so the healing path and the readiness checklist classify a provider identically — loopback/bind-all only, so a LAN/Tailscale peer on port 11434 is NOT claimed as a local daemon), `localRuntimeKind(provider)` (the `*Backed` markers first, then that classifier; `orcarouter` excluded as a remote API), `localRuntimeForProvider(provider)` → the row with the endpoint the provider ITSELF configures (`OPENCODE_CONFIG_CONTENT`'s `baseURL`, `ANTHROPIC_BASE_URL`, or `endpoint`), then the `OLLAMA_URL`/`OLLAMA_HOST`/`LM_STUDIO_URL` override the backend managers read, then the canonical default — and `null` when that resolved endpoint fails `isLocalInstanceEndpoint` (an API provider on another machine has no local daemon to check, whatever its name says) — plus `normalizeOpenAiBaseUrl`. Pure; the probing half is `services/providerReadiness.js`. Optional `setupStateDetail` overrides `providerReadiness`'s per-state prose for a runtime whose local setup is not a model cache (vLLM's is a compose project); `standbyWhenStopped` marks an installed runtime such as llama.cpp whose stopped state is intentional standby rather than incomplete setup. |
| `managedDaemon.js` | Shared mechanism for the local daemons PortOS runs as optional PM2 processes (`services/llamaServerManager.js` → `portos-llama-server`, `services/mtplxServerManager.js` → `portos-mtplx`). Owns their PM2 process names — `LLAMA_APP`, `MTPLX_APP`, and the `isModelServerProcess(name)` predicate over them — so a caller like the CoS health monitor can recognize a model server without importing a manager; the managers re-export the two names. `createDaemonWatcher({...})` supplies the common PM2 launch-line re-adoption, endpoint probe, status skeleton, bounded log view, and port-release wait while managers retain daemon-specific parsing and lifecycle policy. `createDaemonLogBuffer({maxLines?})` is the bounded timestamped ring buffer of what PortOS logged around a launch, plus `withPm2Logs(output)` → that buffer followed by anything `pm2 logs` has which it doesn't already hold, deduped and re-capped (PM2's lines are a VIEW, never folded into the buffer — PM2 owns them and re-reads them every status call). `pm2ArgValue(args, flag)` reads one value back out of a PM2 process's recorded argv so a manager can recover a still-online daemon's launch config after a PortOS restart; `null` means the flag was absent, which a relaunch must leave off rather than defaulting. Also the shared **idle reaper**, for a daemon that cannot release its weights any other way: `registerIdleDaemon({name, getIdleMs, stop})` (seeds `lastUsedAt` to NOW, so a hand-started daemon gets a full window), `markDaemonUsed(name)` — call on real traffic, NEVER on a status poll — `daemonLastUsedAt(name)`, `idleWindowMs(minutes)` (minutes → ms; `0` = never, `null` = not configured, kept distinct), `reapIdleDaemons(now?)` → the names stopped, and `startIdleReaper({intervalMs?})` / `stopIdleReaper()` (ONE interval for all registrants, `unref`'d, idempotent). `mtplxServerManager` is the only registrant: llama.cpp releases its checkpoint in place via `--sleep-idle-seconds` and must NOT be stopped for it. Deliberately mechanism only — what a launch line means and when a daemon may start is exactly what differs between the two. |
| `mtplxModels.js` | `listMtplxCachedModels({command?})` → `{models, error}` from `mtplx models --json` (a local directory listing — no network, no model load) and `pickMtplxCachedModel(models)` → the repo id to hand `mtplx serve --model`. `models: null` means the cache could not be READ (no binary, command failed, unparseable) and is deliberately distinct from `[]` (read, and empty), because `services/localRuntimeSetup.js` starts MTPLX on its own default in the first case and refuses with the `mtplx pull` command in the second. Exists because `mtplx serve` defaults `--model` to one hard-coded checkpoint and exits 1 before binding when that repo is not cached — even on a host holding a different MTP model that serves fine. Picks only entries MTPLX itself calls complete (`validation.ok !== false`, so a half-finished pull is not served), preferring one with a recorded `mtplx_runtime.json` exactness contract. `describeMtplxCache(cache)` → `{state: 'unknown'\|'empty'\|'partial'\|'ready', model, count, error}` folds both into the one value `services/providerReadiness.js` puts on the checklist and `describeRuntimeSetup` picks a button from — so an empty cache is named up front instead of only inside the failure of a Start that could never work. |
| `vllmQwenProject.js` | `resolveVllmProjectDir()` / `inspectVllmQwenProject()` → `{dir, hasProject, composeFile, hasWeights, weightsRoot}` for the operator-cloned syv-ai/qwen38-27b-rtx3090 compose project, plus `vllmStartBlockedReason(project)` → the prose refusal (or `null`), and `vllmProjectSetupState(project)` → `ready`/`empty`/`unknown` for the readiness checklist. Directory reads only — never runs docker or touches a registry. `hasWeights` is tri-state: `true` found / `false` caches read and empty / `null` no cache readable (a docker-volume cache is invisible from a native-Win32 PortOS), and `services/localRuntimeSetup.js` refuses to `docker compose up` on anything but `true` so the start button can never kick off the ~20 GB prepare. Overrides: `VLLM_QWEN_PROJECT_DIR`, `VLLM_QWEN_WEIGHTS_DIR`. |
| `vllmQwenProject.js` | `resolveVllmProjectDir()` / `inspectVllmQwenProject()` → `{dir, hasProject, composeFile, hasWeights, weightsRoot}` for the syv-ai/qwen38-27b-rtx3090 compose project, plus `vllmStartBlockedReason(project)` → the prose refusal (or `null`), and `vllmProjectSetupState(project)` → `ready`/`empty`/`unknown` for the readiness checklist. `hasWeights` is tri-state: `true` found / `false` caches read and empty / `null` no cache readable (a docker-volume cache is invisible from a native-Win32 PortOS), and `services/localRuntimeSetup.js` refuses to `docker compose up` on anything but `true` so the start button can never kick off the ~20 GB prepare. Also owns WHERE the project lives: `readRecordedVllmProjectDir(envPath?)` / `recordVllmProjectDir(dir, envPath?)` keep PortOS's auto-detected directory as one line in the INSTALL's `.env` (`PATHS.installRoot`, so a worktree-booted server writes where the real install reads), and `vllmProjectDirIsSettled()` answers "is detection still worth a subprocess?" so the manager never re-lists the precedence. That order is `VLLM_QWEN_PROJECT_DIR` (this run's decision) → the record → `~/qwen-serving`; `envPath` is a parameter so a test's sandbox answers instead of the developer's install. Directory reads plus that one config line — never docker, never a registry. Other override: `VLLM_QWEN_WEIGHTS_DIR`. |
| `wslDistro.js` | Which WSL2 distro a native-Win32 PortOS should put Linux-side work in. `detectWslProjectDir(leaf)` → `{dir, distro, home}` or `{dir: null, reason}` (`no-wsl` / `no-distro` / `internal-distro` / `unreadable-share`), built from `wsl.exe -e sh -c 'echo "$WSL_DISTRO_NAME"; echo "$HOME"'` — the distro's OWN shell, because `wsl --list` prints UTF-16LE that a UTF-8 reader mangles while an executed program's stdout passes through byte for byte. Verifies the derived `\\wsl.localhost\…` path is readable from Windows before returning it (WSL running and its share answering are separate facts) and refuses a container engine's own distro (`docker-desktop` and friends are recreated on a reset). `parseWslProbe` / `parseWslDistroList` (NUL-stripping the UTF-16 bytes, for an error message only) are exported for their own tests; `WSL_UNC_PREFIX` names the share root for callers writing refusal prose. Exists so the vLLM stack places its ~20 GB of weights on the distro filesystem instead of asking a human to fill in a UNC template — every read from a `C:\` checkout would cross a 9p share. |
| `qwenAgentParsers.js` | The tool-call / reasoning parser flags a local runtime MUST carry to serve a Qwen3-family model to a coding agent — one table PortOS owns instead of three docs. `QWEN_AGENT_PARSERS` maps runtime → `{toolCallParser, reasoningParser, enableAutoToolChoice}` (vLLM `qwen3_xml` + `--enable-auto-tool-choice`, SGLang `qwen3_coder` + `--reasoning-parser qwen3`, llama both `null` — a positive "no such flag today", not a placeholder). `parserFlagsFor(runtime)` returns the argv fragment (always an array, so no caller type-checks) and `vllmExtraArgs()` its string form for the compose project's `.env` `EXTRA_ARGS`; an unknown runtime THROWS, because the failure it prevents is silent — a parser-less server answers fluently and returns tool markup as ordinary text with `tool_calls: null`, so the agent never touches a file. Spellings are empirical (`docs/research/2026-08-21-qwen38-rtx3090-vllm.md`, `…-sglang-qwen38-27b.md`); never auto-detect one from the chat template — that is how `hermes` got picked. Pure. |
| `vllmQwenProvision.js` | The `.env` half of provisioning that same project: `generateVllmApiKey()`, `isWsl2Engine()` (win32 counts — Docker Desktop's engine IS a WSL2 VM), `vllmEnvDefaults({apiKey, wsl2})` → the load-bearing tool-parser/pin-memory/alloc-conf settings, and `mergeEnvFileContents(existing, defaults)` → `{contents, added, kept, effective}`. **Additive only** — an operator's existing key or tuning is never overwritten, and `effective` reports what the container will actually read. Plus `WSL2_PREPARE_MIN_BYTES` / `WSL2_PREPARE_CONFIG_HINT` for the ceiling `prepare` needs (detected and warned about, never raised). |
| `vllmQwenProvision.js` | The `.env` half of provisioning that same project: `generateVllmApiKey()`, `isWsl2Engine()` (win32 counts — Docker Desktop's engine IS a WSL2 VM), `vllmEnvDefaults({apiKey, wsl2})` → the load-bearing tool-parser/pin-memory/alloc-conf settings, `parseEnvContents(contents)` → a key→value Map (keyed on *mention*, so a commented-out key reads as absent and `KEY=` as an intentional empty), and the two writers over one shared newline guard: `mergeEnvFileContents(existing, defaults)` → `{contents, added, kept, effective}` is **additive only** — an operator's existing key or tuning is never overwritten, and `effective` reports what the container will actually read — while `upsertEnvLine(contents, key, value)` REPLACES one key's line, for a value PortOS owns and re-derives (`vllmQwenProject.js`'s recorded project directory); its replacement is a function, not a string, so a `$`-sequence in the value is written literally. Plus `WSL2_PREPARE_MIN_BYTES` / `WSL2_PREPARE_CONFIG_HINT` for the ceiling `prepare` needs (detected and warned about, never raised). |
| `sglangQwenProject.js` | `resolveSglangProjectDir()` / `inspectSglangQwenProject()` → `{dir, hasProject, composeFile, hasWeights, weightsRoot}` for the operator's SGLang Qwen3.8-27B project, plus `sglangStartBlockedReason(project)` → the prose refusal (or `null`). Sibling of `vllmQwenProject.js` with the same tri-state `hasWeights` contract (`true` found / `false` caches read and empty / `null` no cache readable) and the same directory-reads-only rule — never docker, never a registry. Differs in that PortOS OWNS this launch line (SGLang publishes an image but no compose project), so the refusals point at the compose file in `docs/features/sglang-qwen38.md` rather than at a `git clone`. Overrides: `SGLANG_QWEN_PROJECT_DIR`, `SGLANG_QWEN_WEIGHTS_DIR`. |
| `sglangQwenRecipe.js` | The `sglang serve` launch line PortOS owns, per NVIDIA card class. `buildSglangQwenRecipe({hw, contextLength, ssmDtype, spec, radixStrategy, host, port})` → `{image, modelName, modelPath, cell, mambaRatio, stateSlots, flags, env}`, and `sglangComposeYaml(recipe)` renders the `docker-compose.yml` from it (one source of truth; a test pins the doc against it). `mambaFullMemoryRatio(...)` derives `--mamba-full-memory-ratio` from the cookbook formula `(S + D) × state_bytes / (L × kv_bytes_per_token)` — load-bearing, because the cookbook default `0.9` under-sizes the GDN state pool at CoS prompt lengths and silently clamps `max_running_requests`. Both Qwen parsers (`--reasoning-parser qwen3`, `--tool-call-parser qwen3_coder` — NOT vLLM's `qwen3_xml`) are baked into every cell: getting them wrong fails silently, with the model emitting raw markup and the agent never calling a tool. `sglangCellForGpu(gpu)` maps a `cudaCapability.js` compute-cap row to a cell (SM 9.x → `h200`, SM ≥10 → `rtx6000`/`rtx5090` by VRAM, Ampere → `null`, which is a refusal: 24 GB stays on vLLM), and `sglangUnsupportedReason({platform, status, gpus})` is the prose for every no — never collapsing a probe `'unknown'` into "no GPU". Pure: no filesystem, no docker, no network. |
| `opencodeStream.js` | OpenCode's `--format json` event stream — ONE parser, shared by `services/localModelAgentBenchmark.js` (which wants chars/tokens) and `services/modelCapabilityTests.js` (which wants a readable transcript). `eventPart` (accepts the flat `{part}` and nested `{properties.part}` envelopes OpenCode has both used), `isToolEvent` / `eventText`, `parseAgentLine` / `parseAgentEvents` (blank and unparsable lines yield nothing rather than throwing), `formatAgentEvent` (one frame → a transcript line a person can read, with the path or command the tool acted on), and `summarizeOpenCodeEvents` (assistant chars, tool calls, and output tokens — `null`, never 0, when OpenCode reported no usage; tool ARGUMENTS are deliberately not counted as answer text). Pure. The runner that produces the stream is `services/opencodeTask.js`. |
Expand Down
1 change: 1 addition & 0 deletions server/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ export * from './localProviderRuntime.js';
export * from './mtplxModels.js';
export * from './managedDaemon.js';
export * from './vllmQwenProject.js';
export * from './wslDistro.js';
export * from './qwenAgentParsers.js';
export * from './vllmQwenProvision.js';
export * from './sglangQwenProject.js';
Expand Down
Loading