diff --git a/.gitignore b/.gitignore index 75ba703b9a..070b8ca186 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ zig-out/ # Versioned GUI build resources, generated app engines stay ignored in app/.gitignore. !app/build/ +# mHC fp64 qualification receipts. +/receipts/ +*.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cdf94481a..6164762914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,49 @@ Each release's page on GitHub has its notes and measurements. See [the 1.0.0 release notes](RELEASE-NOTES-1.0.0.md) for the native binary's supported models and migration details. +## 1.1.1 + +- The app checks models through HTTPS Hub fake-IP proxies in both address families and honours one validated `HF_ENDPOINT` for checks and downloads. Network-policy refusals name the DNS address and proxy remedy (#1037). Thanks to @samwang0041-star. + +- Flash Next on Metal uses precise exp2, sine and cosine for rotary preparation and index pooling, including M-RoPE (#986, #1005). Thanks to @BobClawblaw, @cwschroeder and @sethforprivacy, retaining the original YaRN attribution. +- GLM-5.3-Flash NVFP4 loads and serves the whole checkpoint on two GB10s at TP=2 in a contributed regression run on shipped 1.1.0: 302.1 seconds to load, one token hash per prompt across three runs, and a passphrase found in 199,307 tokens. Scored prompts and more than two ranks were not covered (#1040). Thanks to @Bizuayeu. + +- Qwen3.8-27B on Metal keeps its Neural Engine prompt share off by default on M5. Its fp16 prompt rows can change replies, so the default follows the no-quality-tradeoffs rule. `TF_ANE_COLUMNS=N` opts in; M1 to M4 already use the GPU alone. The load receipt reports requested and loaded columns, fp16 program-weight bytes and exact-output qualification. + +- Source builds report the release version. A source build of 1.1.0 reported 1.0.12 because its package manifest still carried the older version. +- Qwen on CUDA uses larger verification trees, shared rounds across streams and batched DFlash proposals. Captured launches follow the live row and window sizes. A lone stream chooses its draft width from measured complete-round costs, with separate retry cooldowns for widths that did not repay their cost; straight chains avoid the branch planner and contiguous drafter rows avoid a redundant gather (#978). Thanks to @BobClawblaw and the shared CUDA contributors. +- DeepSeek-V4.1 CUDA pieces land in 1.1.1, but no release binary serves DeepSeek checkpoints until whole-model gate #827 passes. Its implementation includes shared checkpoint planning and Engram token maps, paged and split KV, prompt-cache sessions, keyed sampling and drafting through the lane scheduler. Devices older than sm_90 refuse before family image loading. The shared configuration and host references accompany the CUDA family; a complete DeepSeek Metal backend remains separate (#667, #668, #671, #674, #686, #688, #691, #693, #694, #695, #699, #702, #703, #707, #727, #824, #825, #826, #827). Thanks to @jayleaton and @akol1, retaining Turboderp's ExLlamaV3 attribution and the common EXL3 contributors' credits. +- Kolibri-1's text research checkpoint serves on native CUDA through the lane scheduler, with prompt caching, shared rounds and verified copy drafts. EAGLE-3 and a neural drafter remain unsupported (#991, #481). Thanks to @jschmied and the shared operator contributors. +- GLM CUDA stepped serving interleaves fresh prompt chunks with running decode. Whole-prompt mode can batch eligible fresh text prompts across ranks, keeping each sequence's final prompt row and first draft separate; each pass holds at most 16 prompts and 2,048 rows. Images and resumed prompts keep their own paths (#1014). GLM CUDA prompt projections use one K slice at every operator row count by default. This equal-precision reduction regrouping can change replies versus 1.1.0, including long or resumed prompts whose internal projection calls have fewer than 128 rows. `TF_GLM_PROMPT_K=shape`, with `TF_GLM_MM=1` on every rank, instead uses shape-selected K slices throughout prompt projections, including full blocks; it does not restore 1.1.0's mixed row-count policy. Verify and draft windows retain their existing policy. Thanks to @BobClawblaw. +- GLM on CUDA has an experimental prompt projection policy, `TF_GLM_PROMPT_K=shape`, which chooses K slices from the weight's shape. It requires `TF_GLM_MM=1` and the same setting on every rank. It is off by default, and its different reduction grouping can change near-ties; verify and draft windows keep their existing policy. Thanks to @BobClawblaw and the TensorFold maintainers. +- GLM on CUDA returns prompt log probabilities for nonstreaming `/v1/completions` requests with `prompt_logprobs`. Scoring storage is reserved before collective work, and ordinary requests keep their existing path (#1016). Thanks to @Bizuayeu. +- GLM on CUDA accepts schema-constrained structured output and the guided-output options, including a mask on the first generated token and independent grammar state for each sequence. Drafting observes the same constraints (#1017). Thanks to @Bizuayeu. +- Required and named tool calls on GLM CUDA use the model's tool markup, with schema-constrained argument values and matching parsing on the server (#1018). Thanks to @Bizuayeu. +- The common CUDA runtime has a keyed graph cache and a device arena, enabled only by families that install them. Failed captures or allocations release unpublished resources, and rank agreement keeps tensor-parallel cache decisions together (#635, #682). Thanks to @jayleaton and the shared runtime contributors. +- The common tensor-parallel fabric supports process bootstrap, NCCL, mailbox and RoCE collectives, checks that ranks agree on their settings, and propagates a failed peer instead of leaving the other rank waiting (#636). Thanks to @jayleaton, retaining the shared transport and RoCE attributions. +- Paged-KV families can use shared session memory with prefix reuse, a bounded RAM residency budget and a streamed disk tier on Linux. Failed saves and restores retain the owners needed for a retry (#637). Thanks to @jayleaton and @cwschroeder. +- On SIGTERM or SIGINT, the server gives requests in progress up to `TF_DRAIN_S` to finish, 20 seconds by default, while new engine requests get 503 with `Retry-After`. A second stop signal cuts the drain short; kept states drain before the engine closes (#663). Thanks to @jayleaton. +- Silent streaming responses send keepalives before their first token, every 15 seconds by default. `TF_SSE_KEEPALIVE_S` changes the interval; zero disables it (#663). Thanks to @jayleaton. +- A failed CUDA kernel launch names the kernel, grid, block and shared-memory size alongside the driver error, so a load or inference failure identifies the launch that failed (#635). Thanks to @jayleaton. +- Native CUDA refuses an unsupported lane policy instead of accepting an option that the selected family ignores. Kolibri receives the common lane options. Thanks to @ajensenwaud and @jayleaton. +- CUDA launch lookup can use a runtime-parameter variant for an argument of 1 when no suitable folded-one variant exists. Both preferred matching passes run first, each fallback is logged, and a launch with no matching variant still fails with its argument dump (#1020, #1003). Thanks to @GustavBlack and @cwschroeder. +- New benchmark runs use `community-v2`: a sampled reply that stops naturally after at least 128 tokens counts as complete, and its rate uses the tokens actually delivered. Greedy replies still need the full requested count, and empty replies still fail. Existing v1 receipts retain their rules; v2 runs can be saved and inspected locally, with publication disabled until the receiver and separate rankings support them (#969). Thanks to @BlivionIaG and @easthambuilds. +- The benchmark sends its code workload through `/v1/chat/completions` when the native family declares that it accepts only chat. An attached server uses that route only with `--chat-only`; an empty reply never triggers a route change. V2 receipts record the route, with the prompt and sampling settings unchanged (#1019). Thanks to @BobClawblaw, @BlivionIaG and @easthambuilds, and @jayleaton for the DeepSeek family declaration. +- Qwen3.8-27B on Metal allocates DeltaNet rollback scratch only for the slots whose verification windows need it, instead of reserving a full second state bank at load. Idle or lone use can release that scratch after the previous round has been kept. Recurrence arithmetic and saved prompt states are unchanged. Thanks to the TensorFold maintainers and the Qwen contributors. +- Request completion lines report `ttft`, `decode`, `elapsed` and `queued` seconds from that request's own timestamps, alongside its token rate. Queue time ends at first lane admission, and a cached prompt keeps its own timing (#1025). Thanks to @tpischke. +- Sampled native requests draw a fresh seed when neither the request nor the server supplies one, and completion lines record it for replay. An explicit request seed still overrides the server default. `--prompt-seed` restores the historical prompt-derived fallback; greedy sampling is unchanged (#1026). Thanks to @tpischke. +- The core has a host encoder and decoder for losslessly compressed `csf_32x2_v1` NVFP4 scales, with checked geometry, exception counts and allocation cleanup. This supplies the scale codec for future checkpoint consumers; it does not add a complete FP4-Local family loader (#1021). Thanks to @Bizuayeu, retaining local-inference-lab's format and reference attribution. +- CUDA's checkpoint weight estimate counts each file named in the shard index once, including files in subfolders. A malformed index or a missing or unreadable named shard refuses admission instead of producing an incomplete estimate; checkpoints without an index or weight map keep the flat-directory fallback (#1022). Thanks to @Bizuayeu. +- The core reads per-module ModelOpt `MIXED_PRECISION` recipes and reports unsupported declarations by module name. The existing NVFP4 reader keeps its behavior. This adds recipe descriptions for mixed-format consumers, with complete family loading still separate (#1023). Thanks to @Bizuayeu. +- The CUDA block-FP8 scale packer accepts each rank's global row and input origins, including tensor-parallel shares that begin inside a 128-element scale block. ModelOpt's four-dimensional block scales can be viewed as the equivalent two-dimensional layout without copying. Existing zero-origin packing is unchanged (#1024). Thanks to @Bizuayeu and @jschmied for the original fp8g layout in #548. + +- Missing optional CUDA libraries name the library and the feature that needs it, with the dependencies also listed in `capabilities --json` and the runtime documentation. This makes failures such as GLM's vision path needing `libcublasLt.so.13` distinguishable from a text-only load failure (#1030). Thanks to @ss-cong for the report. +- The native CUDA host forwards the family's first-token handoff flag, so a family that enables it can deliver the first token before doing the next draft (#1031). Thanks to @jrb-ai. +- Contributor links in the README, CHANGELOG and 1.0.0 notes use @jrb-ai after the same account's handle change (#1036). Thanks to @jrb-ai. +- Qwen3.8-27B on CUDA caches all 64 measured decode-window costs between starts, keyed by the executable, bound device, checkpoint identities, KV format, taps and profiling capacity. A miss measures every width; a valid hit still warms this process's resources before ready, without repeating the timed sweep. +- Qwen3.8-27B on CUDA can open a short-context session with DFlash2: calibration uses a temporary cache when the configured context is too small for its warm-up windows. Calibration still finishes during open, before the lane thread starts. +- The runbook documents GB10 efficiency-core pinning with `taskset -c 0-4,10-14` or Docker's `--cpuset-cpus=0-4,10-14` to reduce CPU heat from the two spinning threads per CUDA rank. The reported heat and speed measurements retain their 1.0.12 plus #1016-#1018 scope (#1033). Thanks to @Bizuayeu. + ## 1.1.0 - Structured output and forced tool calls work with Nemotron 3.5 Super VL (`nemotron_h_omni`), whose text vocabulary is in `llm_config`: the server read `vocab_size` only from `text_config` and the top level, and refused those requests (reported in #951). A `vocab_size` that is not a positive integer within the C ABI's range is skipped rather than cast (#953). Thanks to @swbiggart and @BobClawblaw. @@ -51,7 +94,7 @@ Each release's page on GitHub has its notes and measurements. See [the 1.0.0 rel - The EXL3 routed experts' SwiGLU clamps keep a NaN, as torch.clamp does, where they turned it into the limit; on the decode path a limit of 0 now means none, as it already did for prompts. - GLM-5.3-Flash on two CUDA ranks reads Mia-AiLab's EXL3 checkpoint, its 4-bit mcg routed experts on the common EXL3 module, and with `--vision` takes images and video: rank 0's tower encodes them (video needs libtfvideo) and sends their rows and spans to the other rank. The ranks watch each other, so a rank that fails stops the other instead of leaving it waiting, and they agree on the checkpoint's bytes and every media limit before either loads; verify windows reach the decode buffers' 8 rows, and the bf16 linears run on glm_mm's tiles (#855, #976). Thanks to @BobClawblaw. - Qwen3.6-35B-A3B serves on the Qwen CUDA engine through the native server: its routed experts in 4 or 3 bits behind a softmax router (the 3-bit planes run beside a 4-bit shared expert), the MTP layer beside the checkpoint drafting chains and trees, and the dense family's prompt cache and image tower. On a GB10 drafted replies equal plain ones, 17 of 17, and concurrent replies their runs alone (#856, #972). Ours: its router, top-k and combine launches and its MTP layer's are in the CUDA kernel contract, each against float64, with records for nvcc 13.0 and 13.2. Thanks to @BobClawblaw and @CanadaApollo6. -- A backend can ask the lane core to send a stream's first token before it prepares the first drafts, on a whole prompt pass or its last chunk; the Qwen CUDA backend does. The receipts are in `docs/receipts/qwen-cuda-first-token/` (#932). Thanks to @jregan-beasley-smc. +- A backend can ask the lane core to send a stream's first token before it prepares the first drafts, on a whole prompt pass or its last chunk; the Qwen CUDA backend does. The receipts are in `docs/receipts/qwen-cuda-first-token/` (#932). Thanks to @jrb-ai. - Nemotron 3.5 Bolt (74B-A9B) serves on CUDA from NVIDIA's 24 GB checkpoint: puzzle layers, LatentMoE with its experts in GGML's IQ2_XXS and Q8_0 blocks on kernels of ours, image input, prompt states in pinned host memory on a card that cannot hold the prompt cache (the startup line says where they went), shared rounds of streams, and structured output and forced tool calls (#897, #938). On an RTX PRO 6000 (sm_120, nvcc 13.2) its drafted and plain replies equal those of #897's own build, concurrent replies equal their runs alone, and decode runs at 153 to 155 tok/s against that build's 144 to 146, with the same tokens. Ours: a failed load keeps its destinations and GPU parents until recovery proves them unused, the released router top-k kernel stays as it was beside a wide one for Bolt's 496 experts, and a negative `moe_latent_size` is refused before it becomes a width. Thanks to @BobClawblaw and @MiaAI-Lab. - Nemotron Omni takes video through the RADIO tower: frames sampled into groups the tower reads as images, each with its time, and video tokens budgeted apart from image tokens; the reserved video token in user text stays text (#940). Ours: the video config, frame rate, indices, dimensions and grid are refused before any decode or cast. Thanks to @MiaAI-Lab and @cwschroeder. - Gemma 4 serves natively on Metal: the 31B with its own dense and fused kernels on the tensor units, prompts in 512-row chunks, DFlash drafts, images and structured output (#879), and the 12B (`gemma4_unified`) with its unified text and image embedder and its MTP assistant as a native drafter (#975). Gemma's arithmetic runs in its own kernel copies, so no other family's bits move, and its kernels are in the Metal kernel contract (on M5, 12 of 12 at two seeds). Ours: `config.json`'s context and EOS ids are bounded before their casts, and the packed buffers and private pipelines keep one owner through every failure. Thanks to @BobClawblaw. @@ -76,7 +119,7 @@ Each release's page on GitHub has its notes and measurements. See [the 1.0.0 rel - Nemotron's latent MoE on CUDA: every segment of a prompt split into segments reads the shared-expert plans and pair ids its owner builds, where the other segments' buffers carved fresh ones that nothing wrote (#996). Thanks to @plotarmordev. - The benchmark's refusal-body test holds its fake server's sender on an event after the header, so it proves the body is not buffered instead of waiting 20 ms (#998). Thanks to @cwschroeder. - Nemotron 3.5 Super on CUDA has decode-sized forms of its latent top-k (each expert ranked on a thread of its own) and of the checkpoint's NVFP4 experts (32 columns a warp, four steps in flight), each bit-equal to the kernel it replaces and in the kernel contract, with 1,024 experts, 32 picks and rows without a finite score among its cases (#999). Thanks to @plotarmordev. -- Nemotron's tensor-parallel worker sends a job's first message with one row, so a decode step is one message where it carried 16 rows of padding; larger jobs still send their second message (#1000). Thanks to @tovereast. +- Nemotron's tensor-parallel worker sends a job's first message with one row, so a decode step is one message where it carried 16 rows of padding; larger jobs still send their second message (#1000). Thanks to @easthambuilds. - WebP images decode through libwebp 1.6.0's `libwebpdecoder`, loaded at the first WebP from `TENSORFOLD_LIBWEBP`, `lib/` or the system's library path and refused by name when missing or another release, to the RGB bytes Pillow 13.0.0 gives the same file (lossy and lossless, with and without alpha, EXIF-oriented); animated WebP is refused, and TensorFold ships no copy of the library (#1004). Thanks to @BobClawblaw. - GLM-5.3-Flash on CUDA keeps its index keys and gates in 4,096-row rings that rebase before a pass would cross them, 0.674 GiB a stream at a 40,960-token window, so `--parallel 16` fits and 16 streams share one verify of 2 rows each (#984). Ours: an MTP rebase keeps the whole verify window before its cursor, so a rejected proposal rolls back into rows the rings still hold. Thanks to @BobClawblaw. - GLM-5.3-Flash on CUDA can pack its dense linears to 4, 6 or 8 bits at load (`TF_GLM_DENSE_BITS`, `TF_GLM_HEAD_BITS`; 16, bf16, stays the default), the ranks agreeing on the bits; `TF_ROCE_MAX_KB` caps a RoCE gather's shard, 4,096 KiB by default; and NCCL's cuMem allocations are off on the ranks, which on GB10 exhausted the driver's pool under load (#995). Ours: a malformed, zero or overflowing cap is refused before any allocation, and the packed launches are in the kernel contract with their records measured on nvcc 13.0 and 13.2. Thanks to @BobClawblaw. @@ -87,7 +130,7 @@ Each release's page on GitHub has its notes and measurements. See [the 1.0.0 rel - `--mtp-drafts` and `--mtp-confidence` set a CUDA Nemotron stream's MTP depth and the cumulative confidence its drafts need (#997). Ours: they reach the shared lane rounds as well as a lone stream. Thanks to @plotarmordev. - Nemotron 3.5 Super on CUDA verifies windows of up to 32 rows, 31 MTP drafts a round, with its Mamba scan in stages of 32 held to its host twin's bytes in the kernel contract, and a lone stream's copied and Recall chains start at 15 tokens (#1006). Ours: a checkpoint without an MTP head keeps its shared copy lane, and only Super's latent models widen to 32 rows. Thanks to @plotarmordev. - Nemotron's latent shared-expert plans for a decode window are built once at buffer load, one per row count, and on one GPU the shared expert runs on the side stream beside the routed experts (#1002). Ours: shared windows of several sequences and two-rank windows keep the earlier schedule. Thanks to @plotarmordev. -- An attached benchmark run reads the server's `GET /v1/benchmark/manifest`, so a two-rank CUDA run can be ranked when the manifest's facts pass the receipt checks (#980). Ours: an unknown backend is never attested as CUDA, and an attached run whose accepted manifest lacks its rank count or revision says it is unranked. Thanks to @tovereast. +- An attached benchmark run reads the server's `GET /v1/benchmark/manifest`, so a two-rank CUDA run can be ranked when the manifest's facts pass the receipt checks (#980). Ours: an unknown backend is never attested as CUDA, and an attached run whose accepted manifest lacks its rank count or revision says it is unranked. Thanks to @easthambuilds. - Nemotron 3.5 Super on CUDA drafts with its MTP head over the embedded draft vocabulary's FP8 output rows: a stream drafts on the subset head while its last 16 context tokens are draft ids, else on the whole head (#1011). Ours: a failed head construction drains before it frees anything, and gathered rows are checked before any read. Thanks to @plotarmordev. - Nemotron 3.5 Super's checkpoint prompt passes on CUDA run the FP8 layers in 128-row tiles and the NVFP4 experts in 128-pair by 128-column tiles, up writing down's NVFP4 rows and one GPU's down summing the width shares into bf16, and router logits past 32 rows run on 128-row by 128-expert tiles, each bit-equal to the kernels it replaces in the kernel contract (#1008). Thanks to @plotarmordev. - Windows ARM64 builds the native CUDA engine from source, with NVIDIA's RTX Spark N1X (sm_121) among its targets; the device results in `docs/WINDOWS.md` are the contributor's reports (#1010). Ours: copy custody takes its locks and waits from std.Io's threaded mutex and condition, which Windows has; a Windows integrated GPU sizes its pool from CUDA's counts; and a library path resolves in full before it loads. Thanks to @mweinbach and @BobClawblaw. diff --git a/README.md b/README.md index 52c42a325a..7efca45fa3 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ It needs glibc 2.28 or newer and an NVIDIA driver providing `libcuda.so.1`. Keep See the [CUDA runbook](RUNBOOK.md#linux-and-cuda) for runtime libraries and supported GPUs. ```sh -V=1.1.0 +V=1.1.1 case "$(uname -m)" in aarch64|arm64) PLATFORM=linux-aarch64 ;; x86_64) PLATFORM=linux-x86_64 ;; @@ -134,6 +134,7 @@ Conversions are explicitly marked: their upstream weights need the linked conver | Gemma 4 31B, MLX 4-bit | `tensorfold pull mlx-community/gemma-4-31B-it-4bit@696d436c404745a59f30e4939a658162b0a9e57f`
`tensorfold pull z-lab/gemma-4-31B-it-DFlash@eabd648301ce28583cc14757912e5e0f84e152e1` | One M5 Mac; M5 Ultra checked. M1-M4 refused. | [Native 31B](docs/recipes/gemma-4-native.md). Name the drafter with `--drafter`; images with `--vision`. | | Gemma 4 12B, converted MLX 4-bit | `hf download google/gemma-4-12B-it --revision 707f0a3b8a3c7ad586ed01e27eafbad8a27dd0f7 --local-dir ~/models/gemma-4-12b-hf`
`hf download google/gemma-4-12B-it-assistant --revision 46d4c6f13f0ac0ad827b915669b8df9b81c64c51 --local-dir ~/models/gemma-4-12b-assistant` | One M5 Mac; converted text weights 6.3 GB, image weights 100 MB, plus assistant/caches. M1-M4 refused. | [Convert and serve](docs/recipes/gemma-4-12b-native.md). Upstream download needs `hf`; conversion needs Python, mlx and mlx-lm. Serving is native. | | Step-5-Preview, converted affine | `hf download stepfun-ai/Step-5-Preview --local-dir ~/models/step5-preview-fp8` | One 256 GB Mac; text 166.9 GiB, MTP adds 2.5 GiB and vision 3.7 GiB. | [Convert and serve](docs/recipes/step-5-preview.md). The documented 256 GB setup serves converted affine weights; converting the upstream FP8 download needs Python and mlx. | +| Kolibri-1, block FP8 (research) | `tensorfold pull Aleph-Alpha/Kolibri-1@e52eb4627d11516b0c01de49210ab5a4e4061444` | One NVIDIA GPU; GB10 measured; 74 GB checkpoint. | [Kolibri](docs/recipes/kolibri-1.md). Text only; copy drafts, no EAGLE-3/neural drafter. | | Liquid AI d1-3B, decisions | Not documented yet. | Native CUDA; RAM minimum not published. | [API section below](#serve-flags). `/v1/systemone` and `/v1/decisions`. | | Fish Audio Drama 3, speech | Not documented yet. | Native Metal or CUDA; GB10 measured. | [Release audio instructions](CHANGELOG.md#110). `/v1/audio/speech`; checkpoint and `codec.pth` required. | @@ -231,6 +232,7 @@ The binary's `capabilities --json` response lists its supported flags and platfo | `--speed-up FILE` | Rank and link settings for two-Mac Flash Next or GLM serving. | | `--max-tokens N` | Default reply limit, 4096; requests can override it. | | `--temperature T` | Sampling temperature; zero requests greedy decoding. | +| `--prompt-seed` | Use native prompt-derived keys instead of the fresh OS fallback; request and configured default seeds still win. | | `--top-p P`, `--top-k K`, `--min-p P` | Sampling defaults. | | `--thinking`, `--no-thinking` | Choose whether the chat template opens a reasoning block. | | `--reasoning-effort LEVEL` | Default template effort: `low`, `medium`, `high` or `xhigh`. | @@ -375,7 +377,7 @@ Thank you to everyone who has sent TensorFold a pull request, a measurement or a [@johnymoo](https://github.com/johnymoo), [@JordiPosthumus](https://github.com/JordiPosthumus), [@JRaxworthy](https://github.com/JRaxworthy), -[@jregan-beasley-smc](https://github.com/jregan-beasley-smc), +[@jrb-ai](https://github.com/jrb-ai), [@jschmied](https://github.com/jschmied), [@juliankang4](https://github.com/juliankang4), [@kelchm](https://github.com/kelchm), diff --git a/RELEASE-NOTES-1.0.0.md b/RELEASE-NOTES-1.0.0.md index ea5392ccc1..a78661f7c4 100644 --- a/RELEASE-NOTES-1.0.0.md +++ b/RELEASE-NOTES-1.0.0.md @@ -120,7 +120,7 @@ Thank you to all 145 public contributors who have sent TensorFold a pull request [@johnymoo](https://github.com/johnymoo), [@JordiPosthumus](https://github.com/JordiPosthumus), [@JRaxworthy](https://github.com/JRaxworthy), -[@jregan-beasley-smc](https://github.com/jregan-beasley-smc), +[@jrb-ai](https://github.com/jrb-ai), [@jschmied](https://github.com/jschmied), [@juliankang4](https://github.com/juliankang4), [@kingjamez](https://github.com/kingjamez), diff --git a/RUNBOOK.md b/RUNBOOK.md index 67d8ecdc2a..32d9565274 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -170,6 +170,20 @@ zone at 94 C or more for most of the prompt (97.7 C at the top); `TF_HEAT_HIGH=9 at 93.0 C or under, and the first token came 10% later. Archives whose names contain `host-only` are CPU verification artifacts and cannot serve CUDA inference. +### GB10 CPU pinning for long CUDA runs + +In [#1033](https://github.com/ashhart/TensorFold/issues/1033), @Bizuayeu reports two CPU threads spinning per CUDA rank, +even between requests, heating the CPU during long reply runs. Pinning each rank to efficiency cores reduced that heat. + +Logical CPUs `0-4,10-14` are the Cortex-A725 efficiency cores, confirmed on two GB10 units. Verify the CPU layout (`lscpu -e`) on +each host. Add `taskset -c 0-4,10-14` before each rank's existing native launch, +or add `--cpuset-cpus=0-4,10-14` after `docker run` and before the image name in each rank's container launch. +Keep the existing model, GPU, network and rank options. + +On GLM-5.3-Flash NVFP4, version 1.0.12 plus #1016-#1018, TP=2, with heat bands of 92/88 C, @Bizuayeu's pinned run +completed 40/40 replies, peaking at 85.1 C; the unrestricted run stopped at the seventh reply at 94.7 C. +Decode throughput fell 1.1% and prefill throughput 2.7%. These are the contributor's measurements, not 1.1.0 results. + ## Flash Next and paired Metal serving Flash Next reads its MLX 6-bit/group-32 checkpoint directly and creates its native layouts locally. diff --git a/SECURITY.md b/SECURITY.md index 03b6b318eb..6076fcdfb9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -24,7 +24,32 @@ Only the latest release gets security fixes. The Python 0.6.6 engine at the `v0. Out of scope: what a model writes, a model following instructions in text you gave it, and a client sending the server more work than its machine can do. + +## Native HTTP browser boundaries + +When API keys are configured, private native HTTP routes require a valid key by default. Only GET root, `/health`, `/slide`, its eight embedded scripts, +and GET metrics explicitly opened by `--metrics-open` are public exceptions. Public health never resets memory peaks. This policy includes `/systemone` aliases. + +A loopback listener refuses a Host outside localhost and loopback IP names, preventing a DNS-rebinding site from +using its own hostname to read the local API. `--allowed-host NAME` is repeatable and adds an explicit trusted name +without changing the bind address. Names have no scheme, path or port; bracket IPv6 literals. Classification uses the +resolved bound address, including mapped loopback addresses, rather than trusting a request header or peer address. + +On every listener, a browser Origin must have the same scheme, host and effective port as the request's Host. +The native listener is HTTP. `--allowed-origin ORIGIN` is repeatable and adds a trusted HTTP or HTTPS origin, such as +an HTTPS UI behind a TLS proxy. Origins have no path or userinfo. Opaque `null` origins, malformed values and duplicates +are refused. Both checks run before authentication, body processing and routing, even when a valid API key is supplied. +Their HTTP 403 replies name Host/Origin and the relevant exception flag. + +Clients without Origin keep working with a valid Host, and no Content-Type restriction is introduced. +The server sends no CORS headers; an origin exception does not grant cross-origin browser reads. +These browser checks do not replace `--api-key` for network access. See [API headers](docs/api.md#browser-request-headers). + ## Release files Each release's archives have SHA-256 files beside them on the release page, and Homebrew checks the macOS archive's SHA-256. The Mac app is signed and notarized. + +Malformed or folded headers and unsupported request targets are refused before routing. Each request on a pooled +connection is checked afresh. Rejections close before `100 Continue` and unread bodies are not dispatched. Key files +must be regular files without group or other read/write permission; use `chmod 600`. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index db27613ce9..2d7159e621 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -26,6 +26,12 @@ Copyright 2026 The Qwen Team and The HuggingFace Inc. team, Apache License 2.0. See [the license text](LICENSES/Apache-2.0.txt). The same helpers appear in mlx-vlm's `models/qwen4_exp/language.py`, MIT License, Copyright © 2025 Prince Canuma. +The native engine's image and video input for Flash Next follows transformers' `get_rope_index` positions in +`models/qwen4_exp/modeling_qwen4_exp.py` (`zig/src/families/flashnext/vis_prompt.zig`) and its Qwen3-VL video +processor and processor in `models/qwen3_vl/` (frame sampling, `smart_resize`, pair times and the prompt layout; +`zig/src/core/qwen_vl/video.zig`, `zig/src/server/images.zig`). Copyright 2025 and 2026 The Qwen Team and The +HuggingFace Inc. team, Apache License 2.0; see [the license text](LICENSES/Apache-2.0.txt). + ## GLM-5.3-Flash on Apple Silicon The native GLM-5.3-Flash engine (`zig/src/families/glm/`) runs the Python `glm5_next` family's decode kernels. @@ -207,6 +213,13 @@ TensorFold ships no model weights. The `z-lab/Qwen3.8-27B-DFlash2` model card st The optional `incoai/GLM-5.3-Flash-DFlash2` model card states CC BY-NC-ND 4.0, for non-commercial use without derivatives; only the Python engine reads that drafter. Each checkpoint keeps its own license. +The native `zig/src/families/deepseek_v41/` implementation and `zig/tests/deepseek_v41_reference/` +follow [DeepSeek-V4.1-Flash's official inference](https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash/tree/2cba9e42aa026125f3ed06c6d98c1db82f7ca027/inference), +MIT License, Copyright (c) 2023 DeepSeek: the reference's equations, the Engram normalization, +PCG64 multipliers and prime bucket layout, and the pinned chat template come from that revision. +The EXL3 checkpoint reads through the ExLlamaV3 format covered above; no captured CUDA/Triton +kernel assets, full model shards or binary traces are bundled. + ## Structured output: libtfgrammar `lib/libtfgrammar` in the release archives is `zig/src/grammar/tf_grammar.cc` compiled with the C++ sources of @@ -316,3 +329,29 @@ TensorFold ships no Drama 3 weights and no codec weights. [fishaudio/drama-3](ht and its `codec.pth` are under the Fish Audio Community License Agreement v1.0: research and non-commercial use, commercial use below its revenue threshold, and no Hosted Service without a separate license from Fish Audio. Users obtain the checkpoint and accept that license themselves. + +## DeepSeek-V4.1 shared checkpoint forms + +The DeepSeek-V4.1 config and checkpoint planning combine @jayleaton’s chain (#727) and +@akol1’s #825 admission. The reference is DeepSeek AI’s MIT licensed inference at revision +2cba9e42aa026125f3ed06c6d98c1db82f7ca027, Copyright (c) 2023 DeepSeek. +EXL3 scanning reuses the common checkpoint scanner and retains its ExLlamaV3 attribution. + +The shared Engram host code and token-map builder combine @jayleaton’s #668 and @akol1’s +#824, #825 and #826. Normalization, PCG64 and bucket metadata follow the pinned DeepSeek MIT reference +above. nfkd_data.zig is generated Unicode decomposition data from #824; it is kept verbatim. + +## DeepSeek-V4.1 CUDA sources + +The CUDA sources and loading combine @jayleaton’s #671, #674 and #727 contributions. Source comments retain +the original TensorFold Python-kernel blob receipts. Generic EXL3 variants extend the one +common EXL3 operators and their per-slot epilogues; tile decoding is shared. +The ExLlamaV3 MIT attribution, Copyright (c) 2025 Turboderp, applies as described above. +No external reference, captured cubin, model shard or CUDA compiler is bundled by this patch. + +The tensor-parallel fabric also uses the RoCE design described above (`zig/src/fabric/tp/mailbox.cu`, `roce.zig`, `roce_verbs.zig`). + +## Shared EXL3 resolver setup + +The shared EXL3 resolver split and CUDA lane option forwarding retain work from PR #728 by @ajensenwaud, +with the common EXL3 and DeepSeek contributor credits preserved. diff --git a/app/electron/agent/engine/downloads.ts b/app/electron/agent/engine/downloads.ts new file mode 100644 index 0000000000..570225a690 --- /dev/null +++ b/app/electron/agent/engine/downloads.ts @@ -0,0 +1,85 @@ +import type { EngineEvent, ModelPreflight, PullProgress } from '../types' +import { assertRepoId, type EngineBinary } from './paths' +import { isLocalModelId } from './library' +import { startPull, type PullHandle } from './pull' +import type { ModelRevisions } from './model-revisions' + +export type RunningPull = { handle: PullHandle; last: PullProgress } +interface DownloadOptions { + running: Map + revisions: ModelRevisions + env: NodeJS.ProcessEnv + hub: () => string + binary: () => EngineBinary + assertIdle: (repo: string) => void + cached: (key: string) => ModelPreflight | undefined + preflight: (repo: string, revision: string | null) => Promise + invalidate: (repo: string) => void + invalidateInstalled: () => void + refreshInstalled: () => Promise + emit: (event: EngineEvent) => void + pollMs?: number +} + +// Completion becomes visible only after the immutable revision selection is durable. +export class EngineDownloads { + private readonly finished = new Map() + private readonly lastPullEmit = new Map() + constructor(private readonly options: DownloadOptions) {} + forget(repo: string): void { this.finished.delete(repo) } + + list(): PullProgress[] { + const running = [...this.options.running.values()].map((p) => p.last) + return [...running, ...[...this.finished.values()].filter((p) => !this.options.running.has(p.repo))] + } + + async pull(repo: string, revision: string | null = null): Promise { + if (isLocalModelId(repo)) throw new Error('Local library checkpoints load in place and cannot be downloaded by their library ID.') + if (this.options.running.has(assertRepoId(repo))) return this.options.running.get(repo)!.last + await this.options.revisions.read() // Preserve invalid registry bytes and refuse before starting a child. + this.options.assertIdle(repo) + const pre = this.options.cached(JSON.stringify([repo, revision])) ?? await this.options.preflight(repo, revision) + this.options.assertIdle(repo) + if (this.options.running.has(repo)) return this.options.running.get(repo)!.last + if (!['ok', 'installed'].includes(pre.verdict)) throw new Error(pre.reasons[0] ?? `${repo} cannot be downloaded for the built-in engine.`) + if (!pre.resolvedRevision || (pre.drafter && !pre.drafter.resolvedRevision)) throw new Error('Retry the model checks to resolve an immutable checkpoint before downloading.') + const steps = [{ repo, revision: pre.resolvedRevision }, ...(pre.drafter ? [{ repo: pre.drafter.repo, revision: pre.drafter.resolvedRevision! }] : [])] + this.finished.delete(repo) + const handle = startPull({ + binary: this.options.binary(), + env: this.options.env, + hub: this.options.hub(), + steps, + totalBytes: pre.totalBytes, + pollMs: this.options.pollMs ? Math.max(50, this.options.pollMs) : 1000, + onUpdate: (progress) => this.onPullUpdate(progress), + }) + this.options.running.set(repo, { handle, last: handle.progress() }) + this.options.emit({ kind: 'pull', pull: handle.progress() }) + void handle.done.then(async (final) => { + if (final.state === 'done') { + try { await this.options.revisions.commit(steps) } catch (error) { + final = { ...final, state: 'failed', phase: 'Failed', message: (error as Error).message } + } + } + this.options.running.delete(repo) + this.finished.set(repo, final) + this.options.invalidate(repo) + this.options.invalidateInstalled() + this.options.emit({ kind: 'pull', pull: final }) + if (final.state === 'done') await this.options.refreshInstalled().catch(() => {}) + }) + return handle.progress() + } + + private onPullUpdate(progress: PullProgress): void { + if (progress.state !== 'running') return // Completion is emitted only after the selection is durable. + const job = this.options.running.get(progress.repo) + if (job) job.last = progress + const now = Date.now() + if (progress.state === 'running' && now - (this.lastPullEmit.get(progress.repo) ?? 0) < 250) return + this.lastPullEmit.set(progress.repo, now) + this.options.emit({ kind: 'pull', pull: progress }) + } + +} diff --git a/app/electron/agent/engine/engine-env.ts b/app/electron/agent/engine/engine-env.ts new file mode 100644 index 0000000000..13c64a5e15 --- /dev/null +++ b/app/electron/agent/engine/engine-env.ts @@ -0,0 +1,29 @@ +import os from 'node:os' +import { resolveHubEndpoint } from './hub-network' + +const CHILD_ENV_KEYS = ['HOME', 'PATH', 'TMPDIR', 'LANG', 'LC_ALL', 'LC_CTYPE', 'USER', 'LOGNAME', 'SHELL', 'HF_HOME', 'HF_HUB_CACHE', 'HF_HUB_OFFLINE', 'HF_ENDPOINT'] + +// The engine gets ordinary OS values and the Hugging Face cache settings, never the app's provider keys. +export function engineChildEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {} + for (const key of CHILD_ENV_KEYS) if (typeof base[key] === 'string') env[key] = base[key] + if (env.HF_ENDPOINT) env.HF_ENDPOINT = resolveHubEndpoint(env.HF_ENDPOINT) + env.HOME = env.HOME || os.homedir() + env.PATH = env.PATH || '/usr/bin:/bin:/usr/sbin:/sbin' + return env +} + +// A child's view of `base`: the Hub origin that authorized its checks, and no Hub token. +export function pinnedEngineEnv(base: NodeJS.ProcessEnv, endpoint: string): NodeJS.ProcessEnv { + const child = { ...base } + delete child.HF_TOKEN + child.HF_ENDPOINT = endpoint + return Object.freeze(child) +} + +// Snapshot before filtering so Hub checks and native pulls cannot select different origins. +export function engineLaunchEnv(options: { childEnv?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv }) { + const source = { ...(options.childEnv ?? options.env ?? process.env) } + const endpoint = resolveHubEndpoint(source.HF_ENDPOINT) + return { env: pinnedEngineEnv(options.childEnv ? source : engineChildEnv(source), endpoint), endpoint } +} diff --git a/app/electron/agent/engine/hub-endpoint.test.ts b/app/electron/agent/engine/hub-endpoint.test.ts new file mode 100644 index 0000000000..c3dfe7af4f --- /dev/null +++ b/app/electron/agent/engine/hub-endpoint.test.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict' +import { EngineService, engineChildEnv } from './service' +import { engineLaunchEnv } from './engine-env' +import { preflightRepo, searchHub, type PreflightContext } from './hub' +import { HF_ORIGIN } from './hub-network' +import { makeHarness, waitFor } from './test-fixtures/harness' +import { decodeHubFailure } from '../../../shared/hub-failure' + +const GIB = 1024 ** 3, SHA = 'e'.repeat(40), DRAFT_SHA = 'f'.repeat(40) +const ENDPOINT = 'https://mirror.example.test' +const NEXT_ENDPOINT = 'https://other.example.test' +const REPO = 'Org/Model', DRAFTER = 'Org/Drafter' + +function mirror(endpoint: string, withDrafter = false) { + const calls: URL[] = [] + const fetchImpl = (async (input, init) => { + const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) + calls.push(url) + assert.equal(url.origin, endpoint) + assert.equal(init?.redirect, 'manual') + assert.equal(new Headers(init?.headers).has('authorization'), false) + assert.equal(new Headers(init?.headers).has('cookie'), false) + const json = (body: unknown, headers: Record = {}) => new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json', ...headers } }) + if (url.pathname === '/api/models') return json([{ id: REPO }]) + if (url.pathname === `/api/models/${REPO}/revision/main`) return json({ sha: SHA, gated: false }) + if (url.pathname === `/api/models/${REPO}/tree/${SHA}`) return url.searchParams.has('cursor') + ? json([{ type: 'file', size: 20 }]) + : json([{ type: 'file', size: 10 }], { link: `<${endpoint}/api/models/${REPO}/tree/${SHA}?cursor=2>; rel="next"` }) + if (url.pathname === `/${REPO}/resolve/${SHA}/config.json`) return new Response(null, { + status: 307, headers: { location: `/api/resolve-cache/models/${REPO}/${SHA}/config.json` }, + }) + if (url.pathname === `/api/resolve-cache/models/${REPO}/${SHA}/config.json`) return json({ model_type: 'qwen3_5', quantization: { bits: 4, group_size: 64 } }) + if (withDrafter && url.pathname === `/api/models/${DRAFTER}/revision/main`) return json({ sha: DRAFT_SHA, gated: false }) + if (withDrafter && url.pathname === `/api/models/${DRAFTER}/tree/${DRAFT_SHA}`) return json([{ type: 'file', size: 5 }]) + return new Response('missing', { status: 404 }) + }) as typeof fetch + return { calls, fetchImpl } +} + +async function main() { + const env = { HOME: '/placeholder', PATH: '/bin', HF_ENDPOINT: `${ENDPOINT}/`, HF_TOKEN: 'placeholder', OPENAI_API_KEY: 'placeholder' } + assert.deepEqual(engineChildEnv(env), { HOME: '/placeholder', PATH: '/bin', HF_ENDPOINT: ENDPOINT }) + const launch = engineLaunchEnv({ env }) + assert.equal(launch.endpoint, ENDPOINT) + assert.equal(launch.env.HF_ENDPOINT, ENDPOINT) + assert.equal(launch.env.HF_TOKEN, undefined) + assert.equal(launch.env.OPENAI_API_KEY, undefined) + env.HF_ENDPOINT = NEXT_ENDPOINT + assert.equal(launch.env.HF_ENDPOINT, ENDPOINT) + assert.equal(engineLaunchEnv({ env: {} }).endpoint, HF_ORIGIN) + assert.throws(() => engineLaunchEnv({ env: { HF_ENDPOINT: `${ENDPOINT}/base` } }), /TF_HUB_POLICY/) + + const m = mirror(ENDPOINT, true) + assert.equal((await searchHub('test', { fetch: m.fetchImpl, endpoint: ENDPOINT }))[0].repo, REPO) + const ctx: PreflightContext = { endpoint: ENDPOINT, fetch: m.fetchImpl, families: { qwen3_5: ['mlx-q4g64'] }, + totalMemoryBytes: 32 * GIB, memoryLimitBytes: 24 * GIB, installed: new Set(), + drafterFor: () => DRAFTER, cachedBytes: async () => 0, freeDiskBytes: async () => 100 * GIB } + const pre = await preflightRepo(REPO, null, ctx) + assert.equal(pre.verdict, 'ok') + assert.equal(pre.totalBytes, 35) + assert.equal(pre.resolvedRevision, SHA) + assert.equal(pre.drafter?.resolvedRevision, DRAFT_SHA) + for (const required of ['/api/models', `/api/models/${REPO}/revision/main`, `/api/models/${REPO}/tree/${SHA}`, + `/${REPO}/resolve/${SHA}/config.json`, `/api/resolve-cache/models/${REPO}/${SHA}/config.json`, + `/api/models/${DRAFTER}/revision/main`, `/api/models/${DRAFTER}/tree/${DRAFT_SHA}`]) { + assert.ok(m.calls.some((url) => url.pathname === required), required) + } + assert.ok(m.calls.some((url) => url.searchParams.get('cursor') === '2')) + + const h = await makeHarness({ HF_ENDPOINT: `${ENDPOINT}/`, HF_TOKEN: 'placeholder', FAKE_PULL_FILES: '1', FAKE_PULL_SIZE: '64', FAKE_PULL_DELAY_MS: '1' }) + const network = mirror(ENDPOINT) + const events: any[] = [] + const service = new EngineService({ binaryPath: '/fake', binary: h.binary, appHome: h.root, + childEnv: h.env, fetch: network.fetchImpl, totalMemoryBytes: 32 * GIB, pollMs: 20, emit: (e) => events.push(e) }) + const otherCalls: string[] = [] + const other = new EngineService({ binaryPath: '/fake', binary: h.binary, appHome: h.root, + childEnv: { ...h.env, HF_ENDPOINT: NEXT_ENDPOINT }, totalMemoryBytes: 32 * GIB, + fetch: (async (input) => { otherCalls.push(String(input)); return new Response('unavailable', { status: 503 }) }) as typeof fetch, + emit: () => {} }) + try { + assert.equal((await service.preflight(REPO)).verdict, 'ok') + h.env.HF_ENDPOINT = NEXT_ENDPOINT + await service.search('model') + // beta 8's pull queue reports a new pull as queued before its worker starts it + assert.ok(['queued', 'running'].includes((await service.pull(REPO)).state)) + await waitFor(() => events.some((e) => e.kind === 'pull' && e.pull.state === 'done')) + const records = (await h.records()).filter((row) => row.kind === 'pull') + assert.equal(records.length, 1) + assert.equal(records[0].endpoint, ENDPOINT, 'pull uses the endpoint that authorized its preflight') + assert.equal(records[0].hasHubToken, false) + assert.equal(records[0].revision, SHA) + await assert.rejects(() => other.pull(REPO), (error: unknown) => decodeHubFailure(error).category === 'http') + assert.ok(otherCalls.some((url) => url === `${NEXT_ENDPOINT}/api/models/${REPO}/revision/main`), 'another service cannot reuse the first origin verdict') + assert.equal((await h.records()).filter((row) => row.kind === 'pull').length, 1) + const injectedEnv = new EngineService({ binaryPath: '/missing', appHome: h.root, env: { HF_ENDPOINT: ENDPOINT }, fetch: network.fetchImpl, emit: () => {} }) + await injectedEnv.search('env-injection') + injectedEnv.shutdown() + } finally { + service.shutdown(); other.shutdown(); await h.cleanup() + } + const offline = new EngineService({ binaryPath: '/missing', appHome: '/placeholder', env: { HF_HUB_OFFLINE: '1' }, + fetch: (async () => { throw new Error('offline must not fetch') }) as typeof fetch, emit: () => {} }) + await assert.rejects(() => offline.preflight(REPO), (error: unknown) => decodeHubFailure(error).category === 'offline') + offline.shutdown() +} +main().catch((error) => { console.error(error); process.exitCode = 1 }) diff --git a/app/electron/agent/engine/hub-network.test.ts b/app/electron/agent/engine/hub-network.test.ts new file mode 100644 index 0000000000..3c6d500c45 --- /dev/null +++ b/app/electron/agent/engine/hub-network.test.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict' +import { isIP } from 'node:net' +import { checkServerIdentity } from 'node:tls' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { createHubFetch, HF_ORIGIN, resolveHubEndpoint } from './hub-network' +import { assertConnectorBaseUrlResolved } from '../security' +import { isBenchmarkIp, isGloballyRoutableIp } from '../security-addresses' +import { createPinnedLookup } from '../pinned-fetch' +import { decodeHubFailure } from '../../../shared/hub-failure' + +const inside = ['198.18.0.78', '198.18.0.0', '198.19.255.255', '2001:2::1e', + '2001:2:0:ffff:ffff:ffff:ffff:ffff', '2001:0002:0000:0000:0000:0000:0000:001e', + '::ffff:198.18.0.78', '::ffff:c612:4e', '0:0:0:0:0:ffff:c612:004e'] +const outside = ['198.17.255.255', '198.20.0.0', '2001:2:1::', '2001:1:ffff:ffff:ffff:ffff:ffff:ffff'] +const privateAddresses = ['0.0.0.0', '10.0.0.1', '172.16.0.1', '192.168.1.1', '127.0.0.1', + '169.254.169.254', '168.63.129.16', '100.100.100.200', '100.64.0.1', '::', '::1', + 'fe80::1', 'fd00::1', 'fd00:ec2::254', '::ffff:169.254.169.254', '0:0:0:0:0:ffff:a9fe:a9fe'] +const answer = (address: string) => ({ address, family: isIP(address) }) + +async function main() { + assert.equal(resolveHubEndpoint(), HF_ORIGIN) + assert.equal(resolveHubEndpoint(' HTTPS://Hub.Example.Test:443/ '), 'https://hub.example.test') + assert.equal(resolveHubEndpoint('https://fc-mirror.example.test'), 'https://fc-mirror.example.test') + for (const endpoint of ['http://hub.example.test', 'https://198.18.0.78', 'https://[2001:2::1e]', + 'https://localhost', 'https://user:pass@hub.example.test', 'https://@hub.example.test', + 'https://hub.example.test/base', 'https://hub.example.test/?', 'https://hub.example.test/#', + 'https://hub.example.test\\base', 'https://bad_host.test', 'https://hub.exa\nmple.test']) { + assert.throws(() => resolveHubEndpoint(endpoint), /TF_HUB_POLICY/) + } + for (const address of inside) { + assert.equal(isBenchmarkIp(address), true, address) + assert.equal(isGloballyRoutableIp(address), false, address) + let calls = 0 + const fetchImpl = (async (input, init) => { + calls++ + assert.equal(String(input), `${HF_ORIGIN}/api/models?search=x`) + assert.equal(init?.credentials, 'omit') + assert.equal(init?.redirect, 'manual') + assert.equal(typeof (init as any).dispatcher?.dispatch, 'function', 'real pinned dispatcher is present') + assert.equal(new Headers(init?.headers).has('authorization'), false) + return new Response('[]') + }) as typeof fetch + const client = createHubFetch(HF_ORIGIN, { lookup: async (host) => { + assert.equal(host, 'huggingface.co'); return [answer(address)] + }, fetchImpl }) + await (await client(`${HF_ORIGIN}/api/models?search=x`, { redirect: 'manual' })).text() + assert.equal(calls, 1) + await assert.rejects(() => assertConnectorBaseUrlResolved(HF_ORIGIN, 'generic', { + lookup: async () => [answer(address)], + }), /non-public/) + } + for (const address of outside) assert.equal(isBenchmarkIp(address), false, address) + assert.equal(isBenchmarkIp('2001:2::1%en0'), false) + for (const address of privateAddresses) { + for (const answers of [[answer(address)], [answer('198.18.0.78'), answer('2606:4700::1111'), answer(address)]]) { + let calls = 0 + const client = createHubFetch(HF_ORIGIN, { lookup: async () => answers, + fetchImpl: (async () => { calls++; return new Response('unexpected') }) as typeof fetch }) + await assert.rejects(() => client(`${HF_ORIGIN}/api/models`), (error: unknown) => { + const failure = decodeHubFailure(error) + assert.equal(failure.category, 'policy') + assert.ok(failure.message.includes(address)) + assert.match(failure.message, /huggingface.co.*fake-ip-filter.*HF_ENDPOINT/) + assert.doesNotMatch(failure.message, /reconnect|credentials/i) + return true + }) + assert.equal(calls, 0) + } + } + let lookups = 0, transports = 0 + const scoped = createHubFetch('https://mirror.example.test', { lookup: async () => { + lookups++; return [answer('198.18.0.78')] + }, fetchImpl: (async () => { transports++; return new Response('[]') }) as typeof fetch }) + for (const url of ['https://huggingface.co/api/models', 'http://mirror.example.test/api/models', + 'https://mirror.example.test:444/api/models', 'https://user:pass@mirror.example.test/api/models']) { + await assert.rejects(() => scoped(url), /TF_HUB_POLICY/) + } + await assert.rejects(() => scoped('https://mirror.example.test/api/models', { headers: { Cookie: 'placeholder' } }), /TF_HUB_POLICY/) + await assert.rejects(() => scoped(new Request('https://mirror.example.test/api/models', { headers: { Authorization: 'placeholder' } })), /TF_HUB_POLICY/) + await assert.rejects(() => scoped('https://mirror.example.test/api/models', { redirect: 'follow' }), /TF_HUB_POLICY/) + assert.equal(lookups, 0); assert.equal(transports, 0) + await (await scoped('https://mirror.example.test/api/models')).text() + assert.equal(lookups, 1); assert.equal(transports, 1) + + // The socket lookup never asks DNS again and refuses a different hostname. + const lookup = createPinnedLookup('huggingface.co', [answer('198.18.0.78'), answer('2001:2::1e')]) + const resolved = await new Promise((resolve, reject) => lookup('huggingface.co', { all: true }, (error, addresses) => error ? reject(error) : resolve(addresses))) + assert.deepEqual(resolved, [answer('198.18.0.78'), answer('2001:2::1e')]) + await assert.rejects(() => new Promise((resolve, reject) => lookup('other.example.test', {}, (error, address) => error ? reject(error) : resolve(address))), /unexpected host/) + const wrongName = checkServerIdentity('huggingface.co', { subjectaltname: 'DNS:other.example.test' } as any) + assert.equal((wrongName as NodeJS.ErrnoException)?.code, 'ERR_TLS_CERT_ALTNAME_INVALID') + const pinned = readFileSync(path.join(__dirname, '..', 'pinned-fetch.ts'), 'utf8') + assert.match(pinned, /new Agent\(\{ connect: \{ lookup: createPinnedLookup\(expected, pinned\) \} \}\)/) + assert.doesNotMatch(pinned, /rejectUnauthorized\s*:\s*false|checkServerIdentity\s*:/) + for (const code of ['ERR_TLS_CERT_ALTNAME_INVALID', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE']) { + let calls = 0 + const client = createHubFetch(HF_ORIGIN, { lookup: async () => [answer('198.18.0.78')], + fetchImpl: (async (input) => { calls++; assert.equal(new URL(String(input)).hostname, 'huggingface.co'); throw Object.assign(new Error(code), { code }) }) as typeof fetch }) + await assert.rejects(() => client(`${HF_ORIGIN}/api/models`), (error: unknown) => decodeHubFailure(error).category === 'transport') + assert.equal(calls, 1, 'TLS errors never trigger an unpinned retry') + } + const unavailable = createHubFetch(HF_ORIGIN, { lookup: async () => { throw new Error('DNS unavailable') } }) + await assert.rejects(() => unavailable(`${HF_ORIGIN}/api/models`), (error: unknown) => decodeHubFailure(error).category === 'transport') +} +main().catch((error) => { console.error(error); process.exitCode = 1 }) diff --git a/app/electron/agent/engine/hub-network.ts b/app/electron/agent/engine/hub-network.ts new file mode 100644 index 0000000000..47839a8dba --- /dev/null +++ b/app/electron/agent/engine/hub-network.ts @@ -0,0 +1,52 @@ +import { isIP } from 'node:net' +import { createPinnedFetch, resolvePinnedHost, type PinnedHostResolver } from '../pinned-fetch' +import { isAlwaysForbiddenConnectorIp, isBenchmarkIp, isGloballyRoutableIp } from '../security-addresses' +import { hubError, hubFailureCause } from '../../../shared/hub-failure' + +export const HF_ORIGIN = 'https://huggingface.co' + +// Prefix paths are refused so the app's builders and the native pull child share one exact origin. +export function resolveHubEndpoint(value?: string): string { + const raw = value?.trim() || HF_ORIGIN + let url: URL + try { url = new URL(raw) } catch { throw hubError('policy', 'HF_ENDPOINT must be a named HTTPS Hub origin.') } + const host = url.hostname.replace(/^\[|\]$/g, '') + const normalizedHost = host.toLowerCase().replace(/\.$/, '') + const reservedHost = normalizedHost === 'localhost' || normalizedHost.endsWith('.localhost') || normalizedHost === 'metadata.google.internal' + const namedHost = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.?$/i.test(host) + if (!/^https:\/\/[^/?#]+\/?$/i.test(raw) || /[\u0000-\u0020\u007f]/.test(raw) || + url.protocol !== 'https:' || url.username || url.password || raw.includes('@') || + raw.includes('\\') || url.pathname !== '/' || !namedHost || host.length > 253 || + isIP(host) || reservedHost) { + throw hubError('policy', 'HF_ENDPOINT must be a named HTTPS origin without credentials, a path, query or fragment.') + } + return url.origin +} + +export interface HubNetworkOptions { lookup?: PinnedHostResolver; fetchImpl?: typeof fetch } + +export function createHubFetch(endpoint = HF_ORIGIN, options: HubNetworkOptions = {}): typeof fetch { + const origin = resolveHubEndpoint(endpoint) + const hostname = new URL(origin).hostname + return (async (input: Parameters[0], init?: RequestInit) => { + const request = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) + if (request.origin !== origin || request.protocol !== 'https:' || request.username || request.password) { + throw hubError('policy', 'The Hub request must use its configured HTTPS origin without URL credentials.') + } + const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)) + if (headers.has('authorization') || headers.has('cookie')) throw hubError('policy', 'Hub model checks do not accept account tokens or cookies.') + if (init?.redirect === 'follow') throw hubError('policy', 'Hub redirects must be checked and pinned one hop at a time.') + try { + const target = await resolvePinnedHost(request, (address) => { + if (address.includes('%') || isAlwaysForbiddenConnectorIp(address) || (!isGloballyRoutableIp(address) && !isBenchmarkIp(address))) { + throw hubError('policy', `The Hub host ${hostname} resolved to a refused address (${address}). Add this host to the proxy's fake-ip-filter or configure a valid HF_ENDPOINT, then retry.`) + } + }, { lookup: options.lookup }) + return await createPinnedFetch(target, options.fetchImpl ?? fetch)(input, { ...init, headers, credentials: 'omit' }) + } catch (cause) { + const failure = hubFailureCause(cause) + if (failure) throw hubError(failure.category, failure.message, cause) + throw hubError('transport', `Could not reach the Hub host ${hostname}. Check the connection or proxy settings, then retry.`, cause) + } + }) as typeof fetch +} diff --git a/app/electron/agent/engine/hub.test.ts b/app/electron/agent/engine/hub.test.ts index a7ae59ef5a..555501a15d 100644 --- a/app/electron/agent/engine/hub.test.ts +++ b/app/electron/agent/engine/hub.test.ts @@ -110,10 +110,7 @@ async function main() { const routes = repoRoutes('Org/Blocked') routes[`/Org/Blocked/resolve/${SHA}/config.json`] = { status: 307, headers: { location } } const blocked = mockHub(routes) - const result = await preflightRepo('Org/Blocked', null, context(blocked.fetchImpl)) - assert.equal(result.verdict, 'error', 'unread config cannot authorize a model download') - assert.equal(result.formatOk, null) - assert.match(result.reasons.join(' '), /could not be verified/) + await assert.rejects(() => preflightRepo('Org/Blocked', null, context(blocked.fetchImpl)), /TF_HUB_(POLICY|HTTP)/) assert.equal(blocked.calls.some((url) => !url.startsWith(HF_ORIGIN)), false, 'redirects never leave the fixed HTTPS Hugging Face origin') assert.ok(blocked.calls.filter((url) => url.endsWith('/config.json')).length <= 6, 'same-origin redirect loops are bounded') } @@ -177,9 +174,8 @@ async function main() { [`/api/models/Org/Evil/tree/${SHA}`]: { body: [{ type: 'file', path: 'a', size: 10 }], headers: { link: '; rel="next"' } }, [`/Org/Evil/resolve/${SHA}/config.json`]: { text: '{"model_type":"nemotron_h","quantization":{"bits":4,"group_size":64}}' }, }) - const evil = await preflightRepo('Org/Evil', null, context(evilNext.fetchImpl)) - assert.equal(evilNext.calls.some((c) => c.includes('evil.example')), false, 'a cross-origin next link is ignored') - assert.equal(evil.totalBytes, 10) + await assert.rejects(() => preflightRepo('Org/Evil', null, context(evilNext.fetchImpl)), /TF_HUB_POLICY/) + assert.equal(evilNext.calls.some((c) => c.includes('evil.example')), false, 'a cross-origin next link is refused') const missing = mockHub({}) await assert.rejects(() => preflightRepo('Org/Missing', null, context(missing.fetchImpl)), /not found on Hugging Face, or private/) diff --git a/app/electron/agent/engine/hub.ts b/app/electron/agent/engine/hub.ts index 9475350ae0..df80994358 100644 --- a/app/electron/agent/engine/hub.ts +++ b/app/electron/agent/engine/hub.ts @@ -1,5 +1,7 @@ import { readBoundedJsonResponse, readBoundedResponseText } from '../bounded-response' -import { createConnectorFetch } from '../secure-fetch' +import { resolveHubEndpoint } from './hub-network' +import { hubError, hubFailureCause } from '../../../shared/hub-failure' +export { HF_ORIGIN, createHubFetch } from './hub-network' import type { HubGated, HubModelSummary, ModelPreflight, PreflightVerdict } from '../types' import { describeFormat, formatName } from './cli' import { assertRepoId, isRevision } from './paths' @@ -9,29 +11,37 @@ import { discardResponseBody } from '../pinned-fetch' // Hugging Face search and a per-repo preflight: is the family served, is the format one the engine // reads, is the repo gated (pull sends no token), how big is it, does it fit memory and disk. -export const HF_ORIGIN = 'https://huggingface.co' export const RUNTIME_MARGIN_BYTES = 2 * 1024 ** 3 const DISK_HEADROOM_BYTES = 1024 ** 3 const TIMEOUT_MS = 15_000 -export function createHubFetch(): typeof fetch { - return createConnectorFetch(HF_ORIGIN, 'Hugging Face') -} - export interface HubDeps { fetch: typeof fetch + endpoint?: string offline?: boolean } function assertOnline(deps: HubDeps): void { - if (deps.offline) throw new Error('Hugging Face is offline in this session (HF_HUB_OFFLINE is set).') + if (deps.offline) throw hubError('offline', 'Hugging Face is offline in this session (HF_HUB_OFFLINE is set).') +} + +function origin(deps: HubDeps): string { return resolveHubEndpoint(deps.endpoint) } + +async function request(deps: HubDeps, url: string, init: RequestInit): Promise { + try { return await deps.fetch(url, init) } catch (cause) { + const failure = hubFailureCause(cause) + if (failure) throw hubError(failure.category, failure.message, cause) + throw hubError('transport', 'Could not reach the Hub. Check the connection or proxy settings, then retry.', cause) + } } async function getJson(deps: HubDeps, url: string, label: string): Promise { - const res = await deps.fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS), redirect: 'manual' }) - if (res.status === 401 || res.status === 404) throw new Error(`${label}: not found on Hugging Face, or private.`) - if (!res.ok) throw new Error(`${label}: Hugging Face answered HTTP ${res.status}.`) - return readBoundedJsonResponse(res, label, 8 * 1024 * 1024) + const res = await request(deps, url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS), redirect: 'manual' }) + if (res.status === 401 || res.status === 404) { await discardResponseBody(res); throw hubError('http', `${label}: not found on Hugging Face, or private.`) } + if (!res.ok) { await discardResponseBody(res); throw hubError('http', `${label}: Hugging Face answered HTTP ${res.status}.`) } + try { return await readBoundedJsonResponse(res, label, 8 * 1024 * 1024) } catch (cause) { + throw hubError('http', `${label}: the Hub response could not be read as bounded JSON.`, cause) + } } function gatedValue(value: unknown): HubGated { @@ -69,25 +79,27 @@ export async function searchHub(query: string, deps: HubDeps, limit = 30): Promi if (q.length > 200) throw new Error('Search text is too long.') const params = new URLSearchParams({ search: q, limit: String(Math.min(Math.max(1, limit), 50)), sort: 'downloads', direction: '-1' }) for (const field of ['downloads', 'likes', 'lastModified', 'gated', 'config', 'pipeline_tag', 'library_name']) params.append('expand', field) - return parseSearchResults(await getJson(deps, `${HF_ORIGIN}/api/models?${params}`, 'Hugging Face search')) + return parseSearchResults(await getJson(deps, `${origin(deps)}/api/models?${params}`, 'Hugging Face search')) } -function nextLink(header: string | null): string | null { +function nextLink(header: string | null, deps: HubDeps): string | null { const match = /<([^>]+)>;\s*rel="next"/.exec(header ?? '') if (!match) return null - const url = new URL(match[1], HF_ORIGIN) - return url.origin === HF_ORIGIN ? url.toString() : null + const url = new URL(match[1], origin(deps)) + if (url.origin !== origin(deps) || url.username || url.password) throw hubError('policy', 'The Hub file list points outside the configured HTTPS endpoint.') + return url.toString() } -// Sum of file sizes at one revision; follows the tree API's Link pagination on huggingface.co only. +// Sum file sizes at one revision, following pagination only on the configured Hub origin. export async function treeTotal(deps: HubDeps, repo: string, revision: string): Promise<{ bytes: number; files: number }> { - let url: string | null = `${HF_ORIGIN}/api/models/${assertRepoId(repo)}/tree/${encodeURIComponent(revision)}?recursive=true` + let url: string | null = `${origin(deps)}/api/models/${assertRepoId(repo)}/tree/${encodeURIComponent(revision)}?recursive=true` let bytes = 0 let files = 0 for (let page = 0; url && page < 20; page++) { - const res: Response = await deps.fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS), redirect: 'manual' }) - if (!res.ok) throw new Error(`${repo}: the file list is unavailable (HTTP ${res.status}).`) - const next = nextLink(res.headers.get('link')) + const res: Response = await request(deps, url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS), redirect: 'manual' }) + if (!res.ok) { await discardResponseBody(res); throw hubError('http', `${repo}: the file list is unavailable (HTTP ${res.status}).`) } + let next: string | null + try { next = nextLink(res.headers.get('link'), deps) } catch (cause) { await discardResponseBody(res); throw cause } const body = await readBoundedJsonResponse(res, `${repo} file list`, 8 * 1024 * 1024) if (!Array.isArray(body)) throw new Error(`${repo}: the file list has an unexpected shape.`) for (const entry of body) { @@ -97,31 +109,34 @@ export async function treeTotal(deps: HubDeps, repo: string, revision: string): } url = next } + if (url) throw hubError('http', `${repo}: the Hub file list exceeded the pagination limit.`) return { bytes, files } } async function readConfig(deps: HubDeps, repo: string, revision: string): Promise | null> { try { - let url = new URL(`${HF_ORIGIN}/${repo}/resolve/${encodeURIComponent(revision)}/config.json`) + let url = new URL(`${origin(deps)}/${repo}/resolve/${encodeURIComponent(revision)}/config.json`) const signal = AbortSignal.timeout(TIMEOUT_MS) for (let hop = 0; hop <= 5; hop++) { - const res = await deps.fetch(url.href, { signal, redirect: 'manual' }) + const res = await request(deps, url.href, { signal, redirect: 'manual' }) if ([301, 302, 303, 307, 308].includes(res.status)) { const location = res.headers.get('location') await discardResponseBody(res) - if (!location || hop === 5) return null + if (!location || hop === 5) throw hubError('http', `${repo}: the Hub config redirect limit was reached.`) const next = new URL(location, url) - if (next.origin !== HF_ORIGIN || next.username || next.password) return null + if (next.origin !== origin(deps) || next.username || next.password) throw hubError('policy', 'The Hub config redirect leaves its configured HTTPS endpoint.') url = next continue } - if (res.status !== 200) { await discardResponseBody(res); return null } + if (res.status !== 200) { await discardResponseBody(res); throw hubError('http', `${repo}: config.json is unavailable (HTTP ${res.status}).`) } const doc = JSON.parse(await readBoundedResponseText(res, `${repo} config.json`, 4 * 1024 * 1024)) return doc && typeof doc === 'object' && !Array.isArray(doc) ? doc : null } return null - } catch { - return null + } catch (cause) { + const failure = hubFailureCause(cause) + if (failure) throw hubError(failure.category, failure.message, cause) + throw hubError('http', `${repo}: config.json could not be read from the Hub.`, cause) } } @@ -143,7 +158,7 @@ export async function preflightRepo(repo: string, revision: string | null, ctx: assertRepoId(repo) const rev = revision ?? 'main' if (!isRevision(rev)) throw new Error('Expected a branch, tag or commit for the revision.') - const info = await getJson(ctx, `${HF_ORIGIN}/api/models/${repo}/revision/${encodeURIComponent(rev)}`, repo) + const info = await getJson(ctx, `${origin(ctx)}/api/models/${repo}/revision/${encodeURIComponent(rev)}`, repo) if (typeof info?.sha !== 'string' || !/^[0-9a-f]{40}$/.test(info.sha)) throw new Error('The Hub did not resolve the model to an immutable commit. Retry the model checks before downloading.') const sha: string = info.sha const gated = gatedValue(info?.gated) @@ -157,11 +172,15 @@ export async function preflightRepo(repo: string, revision: string | null, ctx: const drafterRepo = supported ? ctx.drafterFor(repo) : null let drafterRevision: string | null = null const drafterTree = drafterRepo ? await (async () => { - const metadata = await getJson(ctx, `${HF_ORIGIN}/api/models/${drafterRepo}/revision/main`, drafterRepo) + const metadata = await getJson(ctx, `${origin(ctx)}/api/models/${drafterRepo}/revision/main`, drafterRepo) if (typeof metadata?.sha !== 'string' || !/^[0-9a-f]{40}$/.test(metadata.sha) || gatedValue(metadata.gated)) return null drafterRevision = metadata.sha return treeTotal(ctx, drafterRepo, drafterRevision!) - })().catch(() => null) : null + })().catch((cause) => { + const failure = hubFailureCause(cause) + if (failure) throw hubError(failure.category, failure.message, cause) + return null + }) : null const totalBytes = tree.bytes + (drafterTree?.bytes ?? 0) const memoryFloorBytes = tree.bytes ? totalBytes + RUNTIME_MARGIN_BYTES : null const fitsMemory = memoryFloorBytes == null ? null : memoryFloorBytes <= ctx.memoryLimitBytes diff --git a/app/electron/agent/engine/model-hub.ts b/app/electron/agent/engine/model-hub.ts new file mode 100644 index 0000000000..a666596431 --- /dev/null +++ b/app/electron/agent/engine/model-hub.ts @@ -0,0 +1,51 @@ +import type { EngineCapabilities, EngineInstalledModel, ModelPreflight } from '../types' +import { FAMILY_TITLES, drafterFor } from './catalog' +import { createHubFetch, preflightRepo, searchHub, type HubDeps } from './hub' +import { assertRepoId, freeDiskBytes, isRevision, repoCachedBytes } from './paths' +import { isLocalModelId } from './library' +import { resolveHubEndpoint } from './hub-network' +import { hubError } from '../../../shared/hub-failure' + +interface ModelHubOptions { + endpoint: string + fetch?: typeof fetch + env: NodeJS.ProcessEnv + totalMemoryBytes: number + capabilities: () => Promise + installed: () => Promise + cacheDir: () => string + memory: () => { limit: number; budget: number } +} + +// A fetch policy and its accepted preflights belong to one immutable endpoint. +export class ModelHub { + private readonly deps: HubDeps + private readonly preflights = new Map() + constructor(private readonly options: ModelHubOptions) { + const endpoint = resolveHubEndpoint(options.endpoint) + this.deps = Object.freeze({ endpoint, fetch: options.fetch ?? createHubFetch(endpoint), + offline: /^(1|true|yes|on)$/i.test(options.env.HF_HUB_OFFLINE ?? '') }) + } + search(query: string) { return searchHub(query, this.deps) } + cached(key: string): ModelPreflight | undefined { return this.preflights.get(key) } + invalidate(repo: string): void { + for (const [key, value] of this.preflights) if (value.repo === repo) this.preflights.delete(key) + } + async preflight(repo: string, revision: string | null): Promise { + assertRepoId(repo) + if (isLocalModelId(repo)) throw new Error('Local library checkpoints load in place. Scan their folder instead of downloading them.') + if (revision != null && !isRevision(revision)) throw new Error('Expected a branch, tag or commit for the revision.') + if (this.deps.offline) throw hubError('offline', 'Hugging Face is offline in this session (HF_HUB_OFFLINE is set).') + const [caps, installed] = await Promise.all([this.options.capabilities(), this.options.installed()]) + const memory = this.options.memory() + const result = await preflightRepo(repo, revision, { ...this.deps, + families: caps.families, familyTitles: FAMILY_TITLES, totalMemoryBytes: this.options.totalMemoryBytes, + memoryLimitBytes: memory.limit, memoryBudgetBytes: memory.budget, + installed: new Set(installed.map((m) => m.repo)), drafterFor, + cachedBytes: (r) => repoCachedBytes(this.options.cacheDir(), r), + freeDiskBytes: () => freeDiskBytes(this.options.cacheDir()), + }) + this.preflights.set(JSON.stringify([repo, revision]), result) + return result + } +} diff --git a/app/electron/agent/engine/model-revisions.test.ts b/app/electron/agent/engine/model-revisions.test.ts index db652451a8..d9f363938c 100644 --- a/app/electron/agent/engine/model-revisions.test.ts +++ b/app/electron/agent/engine/model-revisions.test.ts @@ -145,7 +145,7 @@ async function main() { const noCommit = new EngineService({ binaryPath: '/fake', binary: h.binary, appHome: h.root, childEnv: h.env, fetch: reviewedFetch({ invalidSha: true }), emit: () => {} }) await assert.rejects(() => noCommit.pull(REPO), /immutable commit/) const missingDrafter = new EngineService({ binaryPath: '/fake', binary: h.binary, appHome: h.root, childEnv: h.env, fetch: reviewedFetch({ missingDrafter: true }), emit: () => {} }) - await assert.rejects(() => missingDrafter.pull(PRIMARY), /drafter.*immutable commit/) + await assert.rejects(() => missingDrafter.pull(PRIMARY), /TF_HUB_HTTP/) } finally { for (const service of services) service.shutdown() await h.cleanup() diff --git a/app/electron/agent/engine/service-options.ts b/app/electron/agent/engine/service-options.ts new file mode 100644 index 0000000000..8fba1e8e34 --- /dev/null +++ b/app/electron/agent/engine/service-options.ts @@ -0,0 +1,18 @@ +import type { EngineEvent } from '../types' +import type { EngineBinary } from './paths' + +export interface EngineServiceOptions { + binaryPath: string + appHome: string + env?: NodeJS.ProcessEnv + // Exact environment for engine processes; tests use it to reach the fake engine's knobs. + childEnv?: NodeJS.ProcessEnv + binary?: EngineBinary + fetch?: typeof fetch + totalMemoryBytes?: number + memoryBudgetBytes?: number + readyTimeoutMs?: number + pollMs?: number + emit: (event: EngineEvent) => void + onInstalled?: (repos: string[]) => void +} diff --git a/app/electron/agent/engine/service.ts b/app/electron/agent/engine/service.ts index 1b783a3079..124442efd8 100644 --- a/app/electron/agent/engine/service.ts +++ b/app/electron/agent/engine/service.ts @@ -6,8 +6,10 @@ import { createHash } from 'node:crypto' import type { EngineCapabilities, EngineConnection, EngineEvent, EngineInstalledModel, EngineLearningSnapshot, EngineLibraryScanOptions, EngineModelLibrarySnapshot, EnginePerformance, EngineSnapshot, EngineStatus, HubModelSummary, ModelPreflight, PullProgress, Readiness, EngineModelGenerationSettings, EngineModelPreviewResult, EngineModelComparisonInput, EngineModelComparisonSnapshot } from '../types' import { FAMILY_TITLES, SUGGESTED_MODELS, drafterFor } from './catalog' import { parseCapabilities, parseInfoOutput, parseModelsOutput, parseVersion, runEngineCli, type ModelInfo } from './cli' -import { createHubFetch, preflightRepo, searchHub } from './hub' -import { assertRepoId, freeDiskBytes, hasCachedSnapshot, hubCacheDir, isRevision, repoCacheDir, repoCachedBytes, type EngineBinary } from './paths' +import { ModelHub } from './model-hub' +import { engineLaunchEnv, pinnedEngineEnv } from './engine-env' +export { engineChildEnv } from './engine-env' +import { assertRepoId, hasCachedSnapshot, hubCacheDir, repoCacheDir, repoCachedBytes, type EngineBinary } from './paths' import { setEnginePerformanceRecorder, setEngineServeHook } from './provider' import { createTurnStats, finalizeTurnStats, readEngineTimings, recordModelCall, type ModelCallSample } from '../turn-stats' import { recordEngineContext } from './context' @@ -50,24 +52,13 @@ export interface EngineServiceOptions { benchmarkStore?: ModelBenchmarkStore } -const CHILD_ENV_KEYS = ['HOME', 'PATH', 'TMPDIR', 'LANG', 'LC_ALL', 'LC_CTYPE', 'USER', 'LOGNAME', 'SHELL', 'HF_HOME', 'HF_HUB_CACHE', 'HF_HUB_OFFLINE'] - -// The engine gets ordinary OS values and the Hugging Face cache settings, never the app's provider keys. -export function engineChildEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = {} - for (const key of CHILD_ENV_KEYS) if (typeof base[key] === 'string') env[key] = base[key] - env.HOME = env.HOME || os.homedir() - env.PATH = env.PATH || '/usr/bin:/bin:/usr/sbin:/sbin' - return env -} - function processAlive(pid: number): boolean { try { process.kill(pid, 0); return true } catch (error) { return (error as NodeJS.ErrnoException).code === 'EPERM' } } export class EngineService { readonly supervisor: EngineSupervisor - private readonly env: NodeJS.ProcessEnv + private readonly launch: { env: NodeJS.ProcessEnv; endpoint: string } private readonly totalMemoryBytes: number private version: string | null = null private versionChecked = false @@ -85,9 +76,8 @@ export class EngineService { private sharedEndpointKey: string | null = null private comparisonAdmission: (() => void) | null = null private comparisonRequest: { owner: symbol; model: string; profile: EngineModelGenerationSettings | null } | null = null - private readonly preflights = new Map() private readonly lastPullEmit = new Map() - private hubFetch: typeof fetch | null + private readonly models: ModelHub private readonly revisions: ModelRevisions private readonly preferences: EnginePreferenceStore private readonly library: ModelLibrary @@ -103,10 +93,19 @@ export class EngineService { private learningPurge: Promise | null = null private purgingLearning = false + // A supplied childEnv stays live for the fake engine's knobs; the authorized Hub origin stays pinned either way. + private get env(): NodeJS.ProcessEnv { + return this.options.childEnv ? pinnedEngineEnv(this.options.childEnv, this.launch.endpoint) : this.launch.env + } + constructor(private readonly options: EngineServiceOptions) { - this.env = options.childEnv ?? engineChildEnv(options.env ?? process.env) + this.launch = engineLaunchEnv(options) this.totalMemoryBytes = options.totalMemoryBytes ?? os.totalmem() - this.hubFetch = options.fetch ?? null + this.models = new ModelHub({ + endpoint: this.launch.endpoint, fetch: options.fetch, env: this.env, totalMemoryBytes: this.totalMemoryBytes, + capabilities: () => this.capabilitiesDoc(), installed: () => this.installed(), cacheDir: () => this.hub, + memory: () => ({ limit: this.supervisor.memoryLimitBytes, budget: this.supervisor.memoryBudgetBytes }), + }) this.revisions = new ModelRevisions(options.appHome, () => this.hub) this.preferences = new EnginePreferenceStore(options.appHome) this.library = new ModelLibrary({ appHome: options.appHome, env: this.env, capabilities: () => this.capabilitiesDoc(), memoryLimitBytes: () => this.supervisor.memoryLimitBytes }) @@ -614,38 +613,9 @@ export class EngineService { return out } - async search(query: string): Promise { - return searchHub(query, this.hubDeps()) - } + search(query: string): Promise { return this.models.search(query) } - private hubDeps() { - this.hubFetch ??= createHubFetch() - return { fetch: this.hubFetch, offline: /^(1|true|yes|on)$/i.test(this.env.HF_HUB_OFFLINE ?? '') } - } - - async preflight(repo: string, revision: string | null = null): Promise { - assertRepoId(repo) - if (isLocalModelId(repo)) throw new Error('Local library checkpoints load in place. Scan their folder instead of downloading them.') - if (revision != null && !isRevision(revision)) throw new Error('Expected a branch, tag or commit for the revision.') - // Offline: fail before asking the engine for its families or touching the network. - const hub = this.hubDeps() - if (hub.offline) throw new Error('Hugging Face is offline in this session (HF_HUB_OFFLINE is set).') - const [caps, installed] = await Promise.all([this.capabilitiesDoc(), this.installed()]) - const result = await preflightRepo(repo, revision, { - ...hub, - families: caps.families, - familyTitles: FAMILY_TITLES, - totalMemoryBytes: this.totalMemoryBytes, - memoryLimitBytes: this.supervisor.memoryLimitBytes, - memoryBudgetBytes: this.supervisor.memoryBudgetBytes, - installed: new Set(installed.map((m) => m.repo)), - drafterFor, - cachedBytes: (r) => repoCachedBytes(this.hub, r), - freeDiskBytes: () => freeDiskBytes(this.hub), - }) - this.preflights.set(JSON.stringify([repo, revision]), result) - return result - } + preflight(repo: string, revision: string | null = null): Promise { return this.models.preflight(repo, revision) } pullList(): PullProgress[] { const running = [...this.pulls.values()].map((p) => p.last) @@ -659,7 +629,7 @@ export class EngineService { if (this.pulls.has(assertRepoId(repo))) return this.pulls.get(repo)!.last await this.revisions.read() // Preserve invalid registry bytes and refuse before starting a child. this.assertPullIdle(repo) - const pre = this.preflights.get(JSON.stringify([repo, revision])) ?? await this.preflight(repo, revision) + const pre = this.models.cached(JSON.stringify([repo, revision])) ?? await this.preflight(repo, revision) this.assertModelAdmission() if (this.deletingModel) throw new Error('Wait for model cache cleanup before starting a download.') this.assertPullIdle(repo) @@ -707,7 +677,7 @@ export class EngineService { } private invalidatePreflight(repo: string): void { - for (const [key, value] of this.preflights) if (value.repo === repo) this.preflights.delete(key) + this.models.invalidate(repo) } cancelPull(repo: string): boolean { diff --git a/app/electron/agent/engine/test-fixtures/fake-engine.mjs b/app/electron/agent/engine/test-fixtures/fake-engine.mjs index cbb0ca5ab9..bf700a9b8f 100644 --- a/app/electron/agent/engine/test-fixtures/fake-engine.mjs +++ b/app/electron/agent/engine/test-fixtures/fake-engine.mjs @@ -76,7 +76,7 @@ function infoCommand(repo) { async function pullCommand(spec) { const [repo, revision = 'main'] = spec.split('@') - record({ kind: 'pull', repo, revision, args: argv }) + record({ kind: 'pull', repo, revision, args: argv, endpoint: env.HF_ENDPOINT ?? null, hasHubToken: 'HF_TOKEN' in env }) if (env.FAKE_PULL_FAIL) { console.error(`${repo}: the hub's file tree is unreachable (ConnectionRefused)`); return 1 } const files = Number(env.FAKE_PULL_FILES ?? 3) const size = Number(env.FAKE_PULL_SIZE ?? 64 * 1024) diff --git a/app/electron/agent/security-addresses.ts b/app/electron/agent/security-addresses.ts new file mode 100644 index 0000000000..0d0d32c45f --- /dev/null +++ b/app/electron/agent/security-addresses.ts @@ -0,0 +1,141 @@ +import { BlockList, isIP } from 'node:net' + +// Classify mapped IPv6 by its embedded IPv4, including expanded and URL-normalized spellings. +export function ipv4MappedToDotted(ip: string): string | null { + let a = String(ip || '').toLowerCase().replace(/^\[|\]$/g, '').replace(/%.*$/, '') + if (isIP(a) === 6) { + try { a = new URL(`http://[${a}]/`).hostname.replace(/^\[|\]$/g, '') } catch { return null } + } + if (!a.startsWith('::ffff:')) return null + const tail = a.slice(7) + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(tail)) return tail // ::ffff:a.b.c.d (dotted) form + const m = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(tail) // ::ffff:HHHH:HHHH (hex) form + if (!m) return null + const hi = parseInt(m[1], 16) + const lo = parseInt(m[2], 16) + return [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff].join('.') +} + +export function isLoopbackHost(hostname: string): boolean { + const raw = hostname.toLowerCase().replace(/^\[|\]$/g, '') + const host = ipv4MappedToDotted(raw) ?? raw + return host === 'localhost' || host === '::1' || host.startsWith('127.') +} + +export function isPrivateHost(hostname: string): boolean { + const raw = hostname.toLowerCase().replace(/^\[|\]$/g, '') + const host = ipv4MappedToDotted(raw) ?? raw + if (host === 'metadata.google.internal' || isKnownMetadataIp(host)) return true + if (isLoopbackHost(host)) return true + if (host === '0.0.0.0' || host.startsWith('10.') || host.startsWith('192.168.') || host.startsWith('169.254.')) return true + const m = /^172\.(\d+)\./.exec(host) + if (!!m && Number(m[1]) >= 16 && Number(m[1]) <= 31) return true + const cgnat = /^100\.(\d+)\./.exec(host) + if (!!cgnat && Number(cgnat[1]) >= 64 && Number(cgnat[1]) <= 127) return true + // IPv6 unique-local and link-local literals are private too. + if (host.startsWith('fc') || host.startsWith('fd')) return true + return host.startsWith('fe8') || host.startsWith('fe9') || host.startsWith('fea') || host.startsWith('feb') +} + +// DNS answers need the same private-address checks as URL literals. +export function isPrivateIp(ip: string): boolean { + const addr = ip.toLowerCase().replace(/^\[|\]$/g, '').replace(/%.*$/, '') + const v4 = ipv4MappedToDotted(addr) ?? addr + if (isKnownMetadataIp(v4)) return true + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(v4)) { + const [a, b] = v4.split('.').map(Number) + if (a === 0 || a === 10 || a === 127) return true + if (a === 192 && b === 168) return true + if (a === 169 && b === 254) return true // link-local incl. cloud metadata 169.254.169.254 + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 100 && b >= 64 && b <= 127) return true // CGNAT + return false + } + if (addr === '::1' || addr === '::') return true + if (addr.startsWith('fe8') || addr.startsWith('fe9') || addr.startsWith('fea') || addr.startsWith('feb')) return true // fe80::/10 link-local + if (addr.startsWith('fc') || addr.startsWith('fd')) return true // fc00::/7 unique-local + return false +} + +// Metadata remains forbidden even for connectors that explicitly allow private networks. +const KNOWN_METADATA_IPS = new BlockList() +KNOWN_METADATA_IPS.addAddress('168.63.129.16', 'ipv4') // Azure platform metadata +KNOWN_METADATA_IPS.addAddress('100.100.100.200', 'ipv4') // Alibaba Cloud IMDS +KNOWN_METADATA_IPS.addAddress('fd00:ec2::254', 'ipv6') // AWS IMDS IPv6 endpoint + +export function isKnownMetadataIp(ip: string): boolean { + const raw = String(ip || '').toLowerCase().replace(/^\[|\]$/g, '').replace(/%.*$/, '') + const address = ipv4MappedToDotted(raw) ?? raw + const family = isIP(address) + if (family === 4) return KNOWN_METADATA_IPS.check(address, 'ipv4') + if (family === 6) return KNOWN_METADATA_IPS.check(address, 'ipv6') + return false +} + +// Public egress excludes reserved ranges; private-network opt-ins use a separate policy. +export function isGloballyRoutableIp(ip: string): boolean { + if (isBenchmarkIp(ip) || isKnownMetadataIp(ip)) return false + const raw = String(ip || '').toLowerCase().replace(/^\[|\]$/g, '').replace(/%.*$/, '') + const address = ipv4MappedToDotted(raw) ?? raw + if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(address)) { + const octets = address.split('.').map(Number) + if (octets.length !== 4 || octets.some((value) => value < 0 || value > 255)) return false + const [a, b, c] = octets + if (a === 0 || a === 10 || a === 127 || a >= 224) return false + if (a === 100 && b >= 64 && b <= 127) return false + if (a === 169 && b === 254) return false + if (a === 172 && b >= 16 && b <= 31) return false + if (a === 192 && b === 168) return false + if (a === 192 && b === 0 && (c === 0 || c === 2)) return false + if (a === 192 && b === 88 && c === 99) return false + if (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) return false + if (a === 203 && b === 0 && c === 113) return false + return true + } + if (isIP(address) !== 6) return false + const first = Number.parseInt(address.split(':')[0] || '0', 16) + if (!Number.isFinite(first) || first < 0x2000 || first > 0x3fff) return false + return !address.startsWith('2001:db8:') && address !== '2001:db8::' +} + +export function isLoopbackIp(ip: string): boolean { + const addr = ip.toLowerCase().replace(/^\[|\]$/g, '').replace(/%.*$/, '') + const mapped = ipv4MappedToDotted(addr) ?? addr + return mapped === '::1' || mapped.startsWith('127.') +} + +export function isAlwaysForbiddenConnectorIp(ip: string): boolean { + const addr = ip.toLowerCase().replace(/^\[|\]$/g, '').replace(/%.*$/, '') + const mapped = ipv4MappedToDotted(addr) ?? addr + if (isKnownMetadataIp(mapped)) return true + if (mapped === '0.0.0.0' || mapped === '::') return true + if (mapped.startsWith('169.254.')) return true + return mapped.startsWith('fe8') || mapped.startsWith('fe9') || mapped.startsWith('fea') || mapped.startsWith('feb') +} + +// Benchmark placeholders are admitted only by the separately scoped HTTPS Hub policy. +const BENCHMARK_IPS = new BlockList() +BENCHMARK_IPS.addSubnet('198.18.0.0', 15, 'ipv4') +BENCHMARK_IPS.addSubnet('2001:2::', 48, 'ipv6') +export function isBenchmarkIp(ip: string): boolean { + const raw = String(ip || '').toLowerCase().replace(/^\[|\]$/g, '') + if (raw.includes('%')) return false + const address = ipv4MappedToDotted(raw) ?? raw + const family = isIP(address) + return family === 4 ? BENCHMARK_IPS.check(address, 'ipv4') : family === 6 && BENCHMARK_IPS.check(address, 'ipv6') +} + +export function isLinkLocalOrMetadataHost(hostname: string): boolean { + const host = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/%.*$/, '') + if (host === 'metadata.google.internal' || isKnownMetadataIp(host)) return true + const v4 = ipv4MappedToDotted(host) ?? host + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(v4)) { + const [a, b] = v4.split('.').map(Number) + return a === 0 || (a === 169 && b === 254) + } + return host === '::' || + host.startsWith('fe8') || + host.startsWith('fe9') || + host.startsWith('fea') || + host.startsWith('feb') +} diff --git a/app/electron/agent/security.ts b/app/electron/agent/security.ts index 39add67bd2..5fd59fc16d 100644 --- a/app/electron/agent/security.ts +++ b/app/electron/agent/security.ts @@ -1,11 +1,11 @@ import { isManagedComputeProvider, isLoopbackUrl } from '../../shared/provider-trust' import type { Settings, Provider, McpServerConfig, SecurityRole } from './types' import { resolvePinnedHost, type PinnedHost, type PinnedHostResolver } from './pinned-fetch' -import { BlockList, isIP } from 'node:net' +import { ipv4MappedToDotted, isLoopbackHost, isPrivateHost, isPrivateIp, isKnownMetadataIp, isGloballyRoutableIp, isLoopbackIp, isAlwaysForbiddenConnectorIp, isLinkLocalOrMetadataHost } from './security-addresses' +export { ipv4MappedToDotted, isLoopbackHost, isPrivateHost, isPrivateIp, isKnownMetadataIp, isGloballyRoutableIp } from './security-addresses' import { redactOpaqueSecrets } from './secret-detection' export const SECRET_MASK = '••••••••' - const SECURITY_ROLES = new Set(['viewer', 'operator', 'owner']) export function currentSecurityRoleFromEnv(env: Partial> = process.env): SecurityRole { @@ -13,10 +13,7 @@ export function currentSecurityRoleFromEnv(env: Partial> = process.env, opts: { headless: boolean } = { headless: false }, @@ -317,45 +314,6 @@ export function mergeMcpServerSecrets(next: McpServerConfig, existing?: McpServe return { ...next, env } } -// Return the embedded IPv4 (dotted) for an IPv4-mapped IPv6 address, else null. Node's URL -// parser normalizes [::ffff:169.254.169.254] to the COMPRESSED-HEX form [::ffff:a9fe:a9fe] -// (groups zero-stripped, e.g. 192.168.1.1 -> ::ffff:c0a8:101), so classifying these by their -// real IPv4 is what stops credentials leaking to internal/metadata hosts via the mapped form. -export function ipv4MappedToDotted(ip: string): string | null { - const a = String(ip || '').toLowerCase().replace(/^\[|\]$/g, '').replace(/%.*$/, '') - if (!a.startsWith('::ffff:')) return null - const tail = a.slice(7) - if (/^\d{1,3}(\.\d{1,3}){3}$/.test(tail)) return tail // ::ffff:a.b.c.d (dotted) form - const m = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(tail) // ::ffff:HHHH:HHHH (hex) form - if (!m) return null - const hi = parseInt(m[1], 16) - const lo = parseInt(m[2], 16) - return [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff].join('.') -} - -export function isLoopbackHost(hostname: string): boolean { - const raw = hostname.toLowerCase().replace(/^\[|\]$/g, '') - const host = ipv4MappedToDotted(raw) ?? raw - return host === 'localhost' || host === '::1' || host.startsWith('127.') -} - -export function isPrivateHost(hostname: string): boolean { - const raw = hostname.toLowerCase().replace(/^\[|\]$/g, '') - const host = ipv4MappedToDotted(raw) ?? raw - if (host === 'metadata.google.internal' || isKnownMetadataIp(host)) return true - if (isLoopbackHost(host)) return true - if (host === '0.0.0.0' || host.startsWith('10.') || host.startsWith('192.168.') || host.startsWith('169.254.')) return true - const m = /^172\.(\d+)\./.exec(host) - if (!!m && Number(m[1]) >= 16 && Number(m[1]) <= 31) return true - const cgnat = /^100\.(\d+)\./.exec(host) - if (!!cgnat && Number(cgnat[1]) >= 64 && Number(cgnat[1]) <= 127) return true - // IPv6 unique-local (fc00::/7) and link-local (fe80::/10) literals — without these - // branches a bracketed ULA/link-local host (e.g. [fd12:3456::1]) passes as "not - // private" since it matches none of the IPv4-style string prefixes above. - if (host.startsWith('fc') || host.startsWith('fd')) return true - return host.startsWith('fe8') || host.startsWith('fe9') || host.startsWith('fea') || host.startsWith('feb') -} - export function assertProviderEndpoint(provider: Provider): void { if (provider.transport === 'codex-cli' || provider.transport === 'claude-cli' || provider.transport === 'opencode' || provider.transport === 'kilo' || provider.transport === 'pi-cli' || provider.transport === 'cursor-cli' || provider.transport === 'gemini-cli') return const url = new URL(provider.baseUrl) @@ -373,87 +331,6 @@ export function assertProviderEndpoint(provider: Provider): void { if (isPrivateHost(url.hostname)) throw new Error('Cloud provider URLs cannot point at local/private addresses.') } -// IP-level private/loopback/link-local/ULA/CGNAT check (IPv4, IPv6, and IPv4-mapped IPv6). -// The hostname-string check (isPrivateHost) is bypassable via DNS — a public name can -// resolve to 169.254.169.254 — so we additionally validate the *resolved* address. -export function isPrivateIp(ip: string): boolean { - const addr = ip.toLowerCase().replace(/^\[|\]$/g, '').replace(/%.*$/, '') - const v4 = ipv4MappedToDotted(addr) ?? addr - if (isKnownMetadataIp(v4)) return true - if (/^\d{1,3}(\.\d{1,3}){3}$/.test(v4)) { - const [a, b] = v4.split('.').map(Number) - if (a === 0 || a === 10 || a === 127) return true - if (a === 192 && b === 168) return true - if (a === 169 && b === 254) return true // link-local incl. cloud metadata 169.254.169.254 - if (a === 172 && b >= 16 && b <= 31) return true - if (a === 100 && b >= 64 && b <= 127) return true // CGNAT - return false - } - if (addr === '::1' || addr === '::') return true - if (addr.startsWith('fe8') || addr.startsWith('fe9') || addr.startsWith('fea') || addr.startsWith('feb')) return true // fe80::/10 link-local - if (addr.startsWith('fc') || addr.startsWith('fd')) return true // fc00::/7 unique-local - return false -} - -// Known cloud metadata destinations that are not covered by link-local ranges. These must -// remain forbidden even when a connector explicitly opts into an RFC1918/LAN endpoint. -// BlockList normalizes equivalent IPv6 spellings, avoiding fragile string-only matching. -const KNOWN_METADATA_IPS = new BlockList() -KNOWN_METADATA_IPS.addAddress('100.100.100.200', 'ipv4') // Alibaba Cloud IMDS -KNOWN_METADATA_IPS.addAddress('fd00:ec2::254', 'ipv6') // AWS IMDS IPv6 endpoint - -export function isKnownMetadataIp(ip: string): boolean { - const raw = String(ip || '').toLowerCase().replace(/^\[|\]$/g, '').replace(/%.*$/, '') - const address = ipv4MappedToDotted(raw) ?? raw - const family = isIP(address) - if (family === 4) return KNOWN_METADATA_IPS.check(address, 'ipv4') - if (family === 6) return KNOWN_METADATA_IPS.check(address, 'ipv6') - return false -} - -// Public egress must use a globally-routable unicast address, not merely "not RFC1918". -// This additionally excludes benchmarking, documentation, multicast/reserved IPv4 and -// non-global IPv6 (including NAT64 well-known prefixes). Private-network opt-ins use their -// own narrower policy and do not call this predicate. -export function isGloballyRoutableIp(ip: string): boolean { - const raw = String(ip || '').toLowerCase().replace(/^\[|\]$/g, '').replace(/%.*$/, '') - const address = ipv4MappedToDotted(raw) ?? raw - if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(address)) { - const octets = address.split('.').map(Number) - if (octets.length !== 4 || octets.some((value) => value < 0 || value > 255)) return false - const [a, b, c] = octets - if (a === 0 || a === 10 || a === 127 || a >= 224) return false - if (a === 100 && b >= 64 && b <= 127) return false - if (a === 169 && b === 254) return false - if (a === 172 && b >= 16 && b <= 31) return false - if (a === 192 && b === 168) return false - if (a === 192 && b === 0 && (c === 0 || c === 2)) return false - if (a === 192 && b === 88 && c === 99) return false - if (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) return false - if (a === 203 && b === 0 && c === 113) return false - return true - } - if (isIP(address) !== 6) return false - const first = Number.parseInt(address.split(':')[0] || '0', 16) - if (!Number.isFinite(first) || first < 0x2000 || first > 0x3fff) return false - return !address.startsWith('2001:db8:') && address !== '2001:db8::' -} - -function isLoopbackIp(ip: string): boolean { - const addr = ip.toLowerCase().replace(/^\[|\]$/g, '').replace(/%.*$/, '') - const mapped = ipv4MappedToDotted(addr) ?? addr - return mapped === '::1' || mapped.startsWith('127.') -} - -function isAlwaysForbiddenConnectorIp(ip: string): boolean { - const addr = ip.toLowerCase().replace(/^\[|\]$/g, '').replace(/%.*$/, '') - const mapped = ipv4MappedToDotted(addr) ?? addr - if (isKnownMetadataIp(mapped)) return true - if (mapped === '0.0.0.0' || mapped === '::') return true - if (mapped.startsWith('169.254.')) return true - return mapped.startsWith('fe8') || mapped.startsWith('fe9') || mapped.startsWith('fea') || mapped.startsWith('feb') -} - // Cloud provider guard that also resolves DNS and rejects if ANY resolved address // is internal — closes the DNS-rebinding bypass before a Bearer key is sent. export async function assertProviderEndpointResolved(provider: Provider): Promise { @@ -596,21 +473,6 @@ export async function resolveLocalProviderRequest(rawBaseUrl: string, rawRequest return target } -function isLinkLocalOrMetadataHost(hostname: string): boolean { - const host = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/%.*$/, '') - if (host === 'metadata.google.internal') return true - const v4 = ipv4MappedToDotted(host) ?? host - if (/^\d{1,3}(\.\d{1,3}){3}$/.test(v4)) { - const [a, b] = v4.split('.').map(Number) - return a === 0 || (a === 169 && b === 254) - } - return host === '::' || - host.startsWith('fe8') || - host.startsWith('fe9') || - host.startsWith('fea') || - host.startsWith('feb') -} - export async function assertRemoteOrchestratorUrl(raw: string): Promise { return (await resolveRemoteOrchestratorUrl(raw)).baseUrl } diff --git a/app/electron/cluster-node/testing/tls.ts b/app/electron/cluster-node/testing/tls.ts index 072ef5ac30..ede42c6178 100644 --- a/app/electron/cluster-node/testing/tls.ts +++ b/app/electron/cluster-node/testing/tls.ts @@ -17,7 +17,8 @@ export function ephemeralTls(): { cert: string; key: string } { const validity = sequence(time(new Date(Date.now() - 86_400_000)), time(new Date(Date.now() + 86_400_000))) const san = sequence(oid([2, 5, 29, 17]), der(4, sequence(der(0x82, Buffer.from('localhost')), der(0x87, Buffer.from([127, 0, 0, 1]))))) const constraints = sequence(oid([2, 5, 29, 19]), der(1, Buffer.from([255])), der(4, sequence(der(1, Buffer.from([255]))))) - const serial = randomBytes(16); serial[0] &= 0x7f + // A DER INTEGER must be positive and minimal: a first byte of 0 (about one serial in 256) fails to parse + const serial = randomBytes(16); serial[0] = 0x40 | (serial[0] & 0x3f) const tbs = sequence(der(0xa0, der(2, Buffer.from([2]))), der(2, serial), algorithm, name, validity, name, pair.publicKey.export({ type: 'spki', format: 'der' }) as Buffer, der(0xa3, sequence(san, constraints))) const certificate = sequence(tbs, algorithm, der(3, Buffer.from([0]), sign('sha256', tbs, pair.privateKey))) return { cert: '-----BEGIN CERTIFICATE-----\n' + certificate.toString('base64').match(/.{1,64}/g)!.join('\n') + '\n-----END CERTIFICATE-----\n', key: pair.privateKey.export({ type: 'pkcs8', format: 'pem' }) as string } diff --git a/app/scripts/model-selection-language.test.mjs b/app/scripts/model-selection-language.test.mjs index 50239b7742..72e0cf6cd6 100644 --- a/app/scripts/model-selection-language.test.mjs +++ b/app/scripts/model-selection-language.test.mjs @@ -21,6 +21,7 @@ const empty = () => null; const row = props => { click = props.onLoad; return null }; const engine = { ENGINE_PROVIDER_ID: 'tensorfold', useEngine: () => ({ snapshot: { status: { state: 'idle', available: true }, installed: [{ repo: 'synthetic/model', title: 'Synthetic', fitsMemory: true }], suggestions: [], pulls: [] } }), loadEngineModel: async () => { loads++ }, refreshEngine: async () => {} }; const { Models } = load('src/components/Models.tsx', { + '../../shared/hub-failure': load('shared/hub-failure.ts', {}), react: React, 'react/jsx-runtime': req('react/jsx-runtime'), clsx: req('clsx'), './OriginalDiagnostic': { OriginalDiagnostic: empty }, '../lib/preferences/language-packs': { authoredText }, '../lib/preferences/useAuthoredText': { useAuthoredText }, '../lib/store': { useStore }, '../lib/ipc': { api: {} }, '../lib/engine': engine, './clusters/ClusterWorkspace': { ClusterWorkspace: empty }, diff --git a/app/shared/hub-failure.ts b/app/shared/hub-failure.ts new file mode 100644 index 0000000000..4a0781b499 --- /dev/null +++ b/app/shared/hub-failure.ts @@ -0,0 +1,44 @@ +// Explicit codes survive Electron's message-only error copy without sharing main-process classes. +export type HubFailureCategory = 'policy' | 'transport' | 'offline' | 'http' | 'check' +export interface HubFailure { category: HubFailureCategory; message: string; coded: boolean } +const CODE = /^\[TF_HUB_(POLICY|TRANSPORT|OFFLINE|HTTP|CHECK)\]\s*/ + +function errorMessage(error: unknown): string { + if (typeof error === 'string') return error + if (error && typeof error === 'object' && typeof (error as { message?: unknown }).message === 'string') return (error as Error).message + return 'Model checks could not be completed.' +} + +export function decodeHubFailure(error: unknown): HubFailure { + const text = errorMessage(error).replace(/^Error invoking remote method '[^']*':\s*/, '').replace(/^(?:Error:\s*)+/, '') + const match = CODE.exec(text) + return { category: match ? match[1].toLowerCase() as HubFailureCategory : 'check', message: match ? text.slice(match[0].length) : text, coded: !!match } +} + +export function hubFailureCause(error: unknown): HubFailure | null { + let current = error + for (let depth = 0; current && depth < 8; depth++) { + const failure = decodeHubFailure(current) + if (failure.coded) return failure + current = typeof current === 'object' ? (current as { cause?: unknown }).cause : null + } + return null +} + +export function hubError(category: HubFailureCategory, message: string, cause?: unknown): Error { + return new Error(`[TF_HUB_${category.toUpperCase()}] ${message}`, { cause }) +} + +export function aggregateHubFailures(kinds: readonly HubFailureCategory[]): HubFailureCategory | null { + return (['policy', 'offline', 'transport', 'http', 'check'] as const).find((kind) => kinds.includes(kind)) ?? null +} + +export function hubFailureCopy(kind: HubFailureCategory) { + switch (kind) { + case 'policy': return { title: 'Model checks blocked by network policy', detail: 'The Hub address was refused. Check your DNS or proxy settings, then retry.' } + case 'transport': return { title: 'Could not reach the Hub', detail: 'Check your connection or proxy settings, then retry.' } + case 'offline': return { title: 'Model checks are offline', detail: 'HF_HUB_OFFLINE is set for this session. Turn it off to check models.' } + case 'http': return { title: 'The Hub could not complete model checks', detail: 'The Hub answered an error. Read the model details, then retry.' } + case 'check': return { title: 'Model checks could not be completed', detail: 'Read the model details, then retry.' } + } +} diff --git a/app/src/components/Models.test.tsx b/app/src/components/Models.test.tsx index f54b002393..cd43a3683b 100644 --- a/app/src/components/Models.test.tsx +++ b/app/src/components/Models.test.tsx @@ -6,6 +6,7 @@ import React from 'react' import ts from 'typescript' import { renderToStaticMarkup } from 'react-dom/server' import { orderFavoritesFirst } from '../lib/favorite-models' +import * as hubFailure from '../../shared/hub-failure' let slots: any[] = [], refs: any[] = [], effects: any[] = [], queued: any[] = [] let cursor = 0, refCursor = 0, effectCursor = 0 @@ -37,6 +38,7 @@ const imports: any = { } for (const name of Object.keys(placeholders)) if (!['HubRow', 'InstalledRow', 'ModelBenchmarkDrawer', 'ClusterWorkspace'].includes(name)) imports[`./${name === 'ModelFoldersSettings' ? 'settings' : 'models'}/${name}`] = { [name]: placeholders[name] } imports['./clusters/ClusterWorkspace'] = { ClusterWorkspace: placeholders.ClusterWorkspace } +imports['../../shared/hub-failure'] = hubFailure imports['./models/benchmark/ModelBenchmarkDrawer'] = { ModelBenchmarkDrawer: placeholders.ModelBenchmarkDrawer } const loaded = { exports: {} as any } new Function('require', 'module', 'exports', ts.transpileModule(readFileSync(path.join(__dirname, 'Models.tsx'), 'utf8'), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true } }).outputText)((id: string) => { const reviewed = reviewedLanguageImport(id, imports); if (reviewed.matched) return reviewed.value; assert.ok(id in imports, id); return imports[id] }, loaded, loaded.exports) diff --git a/app/src/components/Models.tsx b/app/src/components/Models.tsx index f8555643b2..5a81311a53 100644 --- a/app/src/components/Models.tsx +++ b/app/src/components/Models.tsx @@ -1,3 +1,4 @@ +import { aggregateHubFailures, decodeHubFailure } from '../../shared/hub-failure' import { ClusterWorkspace } from './clusters/ClusterWorkspace' import { OriginalDiagnostic } from './OriginalDiagnostic' import { authoredText } from '../lib/preferences/language-packs' @@ -37,7 +38,7 @@ type WorkspaceTab = typeof TABS[number] type Utility = 'downloads' | 'folders' | 'runtime' | 'compare' | 'benchmark' | 'clusters' | null const TAB_LABEL: Record = { get Models() { return authoredText(useLocaleStore.getState().settings?.uiPreferences?.locale, "My models") }, get Discover() { return authoredText(useLocaleStore.getState().settings?.uiPreferences?.locale, "Discover") }, get Workers() { return authoredText(useLocaleStore.getState().settings?.uiPreferences?.locale, "Workers") }, get Learning() { return authoredText(useLocaleStore.getState().settings?.uiPreferences?.locale, "Learning") }, API: 'API' } const route = (model: EngineInstalledModel) => ({ providerId: ENGINE_PROVIDER_ID, accountId: null, model: model.repo }) -function errorText(error: unknown): string { return String((error as Error)?.message ?? error).replace(/^Error invoking remote method '[^']*':\s*(Error:\s*)?/, '') } +function errorText(error: unknown): string { return decodeHubFailure(error).message } export function Models({ minimal = false }: { minimal?: boolean } = {}) { const tx = useAuthoredText() @@ -82,7 +83,7 @@ export function Models({ minimal = false }: { minimal?: boolean } = {}) { const result = await api.enginePreflight(repo, revision) if (mounted.current) setChecks((current) => ({ ...current, [repo]: { loading: false, result, error: null } })) } catch (error) { - if (mounted.current) setChecks((current) => ({ ...current, [repo]: { loading: false, result: null, error: errorText(error) } })) + if (mounted.current) setChecks((current) => ({ ...current, [repo]: { loading: false, result: null, error: errorText(error), errorKind: decodeHubFailure(error).category } })) } }, []) const suggestions = snapshot?.suggestions ?? [] @@ -133,6 +134,7 @@ export function Models({ minimal = false }: { minimal?: boolean } = {}) { const pullFor = (repo: string) => pulls.find((pull) => pull.repo === repo) const recommendation = recommendEngineModel({ status, installed, suggestions, preflights: Object.fromEntries(Object.entries(checks).map(([repo, state]) => [repo, state.result])) }) const checkFailed = suggestions.length > 0 && suggestions.every((suggestion) => checks[suggestion.repo] && !checks[suggestion.repo].loading) && suggestions.some((suggestion) => !!checks[suggestion.repo]?.error) + const failureKind = aggregateHubFailures(suggestions.flatMap((suggestion) => checks[suggestion.repo]?.error ? [checks[suggestion.repo].errorKind ?? 'check'] : [])) const lowered = libraryQuery.trim().toLocaleLowerCase() const shown = orderFavoritesFirst(installed.filter((model) => (!favoritesOnly || favorites.isFavorite(route(model))) && (!lowered || [model.title, model.repo, model.family, model.format].some((value) => value?.toLocaleLowerCase().includes(lowered)))), favorites.favorites, route) const favoriteCount = installed.filter((model) => favorites.isFavorite(route(model))).length @@ -145,7 +147,7 @@ export function Models({ minimal = false }: { minimal?: boolean } = {}) { if (activeThreads.length && !activeThreads.some(isLocalWorker)) useStore.getState().openThread(activeThreads[0].id) else setTab('Workers') } - const recommendationPanel = void useModel(repo)} onDownload={(repo) => void download(repo, revisionFor(repo))} onRetry={() => { for (const suggestion of suggestions) void check(suggestion.repo, suggestion.revision, true) }} /> + const recommendationPanel = void useModel(repo)} onDownload={(repo) => void download(repo, revisionFor(repo))} onRetry={() => { for (const suggestion of suggestions) void check(suggestion.repo, suggestion.revision, true) }} /> const tabs = minimal ? ['Models', 'Discover', 'API'] as const : TABS return
diff --git a/app/src/components/models/ModelRecommendationPanel.test.tsx b/app/src/components/models/ModelRecommendationPanel.test.tsx new file mode 100644 index 0000000000..254b1d16b6 --- /dev/null +++ b/app/src/components/models/ModelRecommendationPanel.test.tsx @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import ts from 'typescript' +import * as failures from '../../../shared/hub-failure' + +const jsx = { jsx: (type: any, props: any, key: any) => React.createElement(type, { ...props, ...(key == null ? {} : { key }) }), + jsxs: (type: any, props: any, key: any) => React.createElement(type, { ...props, ...(key == null ? {} : { key }) }), Fragment: React.Fragment } +const source = readFileSync(path.join(__dirname, 'ModelRecommendationPanel.tsx'), 'utf8') +const output = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true } }).outputText +function MockButton(props: { children?: React.ReactNode; disabled?: boolean }) { + return React.createElement('button', { disabled: props.disabled }, props.children) +} +const imports: Record = { + '../../lib/preferences/useAuthoredText': { + useAuthoredText: () => (text: string, values: Record = {}) => + text.replace(/\{([^}]+)\}/g, (match, key) => key in values ? String(values[key]) : match), + }, + 'react/jsx-runtime': jsx, '../../lib/engine': { formatBytes: () => '1 GiB' }, + '../ui': { Button: MockButton }, + '../../../shared/hub-failure': failures, +} +const loaded = { exports: {} as any } +new Function('require', 'module', 'exports', output)((id: string) => { assert.ok(id in imports, id); return imports[id] }, loaded, loaded.exports) +const Panel = loaded.exports.ModelRecommendationPanel +const recommendation = { status: 'checking', selected: null, fallback: null, reason: 'Checking models.', hardwareLabel: 'Apple silicon Mac' } +for (const kind of ['policy', 'transport', 'offline', 'http', 'check'] as const) { + const html = renderToStaticMarkup(React.createElement(Panel, { recommendation, checkFailed: true, failureKind: kind, + locked: false, starting: false, onStart: () => {}, onDownload: () => {}, onRetry: () => {} })) + assert.ok(html.includes(failures.hubFailureCopy(kind).title)) + assert.ok(html.includes('Retry checks')) + assert.ok(!html.includes('Download model'), 'a failed check cannot authorize a download') + assert.doesNotMatch(html, /reconnect|credentials/i) +} +const policyUnavailable = renderToStaticMarkup(React.createElement(Panel, { recommendation: { ...recommendation, status: 'unavailable' }, + checkFailed: true, failureKind: 'policy', locked: false, starting: false, onStart: () => {}, onDownload: () => {}, onRetry: () => {} })) +assert.ok(policyUnavailable.includes(failures.hubFailureCopy('policy').title)) +const installed = { repo: 'Org/Installed', title: 'Installed model', installed: true, action: 'load', memoryBytes: 1024 } +const verified = renderToStaticMarkup(React.createElement(Panel, { recommendation: { ...recommendation, status: 'ready', selected: installed }, + checkFailed: true, failureKind: 'policy', locked: false, starting: false, onStart: () => {}, onDownload: () => {}, onRetry: () => {} })) +assert.ok(verified.includes('Recommended: Installed model')) +assert.ok(!verified.includes(failures.hubFailureCopy('policy').title), 'verified installed models remain usable') diff --git a/app/src/components/models/ModelRecommendationPanel.tsx b/app/src/components/models/ModelRecommendationPanel.tsx index cf138c5831..671a1abffb 100644 --- a/app/src/components/models/ModelRecommendationPanel.tsx +++ b/app/src/components/models/ModelRecommendationPanel.tsx @@ -1,21 +1,24 @@ +import { hubFailureCopy, type HubFailureCategory } from '../../../shared/hub-failure' import { useAuthoredText } from '../../lib/preferences/useAuthoredText' import { formatBytes } from '../../lib/engine' import type { ModelRecommendation } from '../../lib/model-recommendation' import type { PullProgress } from '../../lib/types' import { Button } from '../ui' -export function ModelRecommendationPanel({ recommendation, pull, starting, locked, checkFailed, onStart, onDownload, onRetry, minimal = false }: { +export function ModelRecommendationPanel({ recommendation, pull, starting, locked, checkFailed, failureKind, onStart, onDownload, onRetry, minimal = false }: { minimal?: boolean recommendation: ModelRecommendation pull?: PullProgress starting: boolean locked: boolean checkFailed: boolean + failureKind?: HubFailureCategory | null onStart: (repo: string) => void onDownload: (repo: string) => void onRetry: () => void }) { const tx = useAuthoredText() + const failure = hubFailureCopy(failureKind ?? 'check') const model = recommendation.selected const downloading = !!pull && ['queued', 'running', 'verifying'].includes(pull.state) return ( @@ -23,8 +26,8 @@ export function ModelRecommendationPanel({ recommendation, pull, starting, locke

{recommendation.hardwareLabel}

-

{model ? tx("Recommended: {value0}", { value0: model.title }) : recommendation.status === 'unsupported' ? tx("This build needs an Apple silicon Mac") : recommendation.status === 'unavailable' ? tx("No compatible recommendation yet") : checkFailed ? tx("Model checks need a connection") : tx("Finding a model for this Mac")}

-

{!model && checkFailed ? tx("Compatibility checks could not finish; reconnect and retry, or choose a verified installed model.") : recommendation.reason}

+

{model ? tx("Recommended: {value0}", { value0: model.title }) : recommendation.status === 'unsupported' ? tx("This build needs an Apple silicon Mac") : checkFailed ? tx(failure.title) : recommendation.status === 'unavailable' ? tx("No compatible recommendation yet") : tx("Finding a model for this Mac")}

+

{!model && checkFailed ? tx(failure.detail) : recommendation.reason}

{model &&

{formatBytes(model.memoryBytes)} {tx("memory required")}{recommendation.fallback ? tx("; {value0} uses less memory", { value0: recommendation.fallback.title }) : ''}.

}
{model &&
diff --git a/app/src/components/models/ModelRows.tsx b/app/src/components/models/ModelRows.tsx index 2971bb7c21..d23c1dfb83 100644 --- a/app/src/components/models/ModelRows.tsx +++ b/app/src/components/models/ModelRows.tsx @@ -1,3 +1,4 @@ +import type { HubFailureCategory } from '../../../shared/hub-failure' import { currentIntlLocale } from '../../lib/preferences/current-locale' import { OriginalDiagnostic } from '../OriginalDiagnostic' import { useAuthoredText } from '../../lib/preferences/useAuthoredText' @@ -165,6 +166,7 @@ export function PullRow({ pull, onCancel, onRetry }: { pull: PullProgress; onCan } export interface PreflightState { + errorKind?: HubFailureCategory | null loading: boolean result: ModelPreflight | null error: string | null diff --git a/app/src/lib/hub-failure.test.ts b/app/src/lib/hub-failure.test.ts new file mode 100644 index 0000000000..1ff1f5df07 --- /dev/null +++ b/app/src/lib/hub-failure.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict' +import { aggregateHubFailures, decodeHubFailure, hubError, hubFailureCopy, type HubFailureCategory } from '../../shared/hub-failure' + +for (const category of ['policy', 'transport', 'offline', 'http', 'check'] as HubFailureCategory[]) { + const message = category === 'policy' + ? 'Hub host huggingface.co refused address 10.0.0.1. Use fake-ip-filter or HF_ENDPOINT.' : `${category} detail` + const mainError = hubError(category, message) + // Electron drops main-process class identity and custom properties, preserving only the message. + const serialized = JSON.parse(JSON.stringify({ message: `Error invoking remote method 'engine:preflight': Error: ${mainError.message}` })) + const failure = decodeHubFailure(serialized) + assert.equal(failure.category, category) + assert.equal(failure.message, message) + assert.equal(failure.coded, true) + assert.ok(hubFailureCopy(category).title) +} +assert.equal(decodeHubFailure(new Error('unexpected validation failure')).category, 'check') +assert.equal(decodeHubFailure('[TF_HUB_OTHER] unknown code').category, 'check') +assert.equal(aggregateHubFailures(['transport', 'http', 'policy']), 'policy') +assert.equal(aggregateHubFailures(['check', 'offline']), 'offline') +assert.equal(aggregateHubFailures(['http', 'transport']), 'transport') +assert.equal(aggregateHubFailures([]), null) +const policy = hubFailureCopy('policy') +assert.equal(policy.title, 'Model checks blocked by network policy') +assert.equal(policy.detail, 'The Hub address was refused. Check your DNS or proxy settings, then retry.') +assert.doesNotMatch(`${policy.title} ${policy.detail}`, /reconnect|credentials/i) +assert.equal(hubFailureCopy('transport').title, 'Could not reach the Hub') +assert.equal(hubFailureCopy('offline').title, 'Model checks are offline') +assert.notEqual(hubFailureCopy('http').title, hubFailureCopy('transport').title) diff --git a/build.zig b/build.zig index 8361b06746..7160cb7934 100644 --- a/build.zig +++ b/build.zig @@ -1,3 +1,4 @@ +const metal_build = @import("zig/build/metal_targets.zig"); const std = @import("std"); const glm_expiry_build = @import("zig/build/glm_expiry.zig"); const builtin = @import("builtin"); @@ -34,56 +35,18 @@ const kimi_kernels = metal_lists.kimi_kernels; const nemotron_packed = metal_lists.nemotron_packed; /// MLX's language version for mx.fast.metal_kernel on this macOS: 4.1 from 27, 4.0 on 26, 3.2 on 15, else 3.1. -fn mlxMetalStd(b: *std.Build) []const u8 { - const os = b.graph.host.result.os; - const major = if (os.tag == .macos) os.version_range.semver.min.major else 0; - if (major >= 27) return "metal4.1"; - if (major >= 26) return "metal4.0"; - if (major >= 15) return "metal3.2"; - return "metal3.1"; -} +const mlxMetalStd = metal_build.mlxMetalStd; /// xcrun metal -c each source to .air with `flags`, then link them into `name`.metallib. -fn metallib(b: *std.Build, name: []const u8, sources: []const []const u8, flags: []const []const u8) std.Build.LazyPath { - const link = b.addSystemCommand(&.{ "xcrun", "-sdk", "macosx", "metallib", "-o" }); - const out = link.addOutputFileArg(b.fmt("{s}.metallib", .{name})); - for (sources) |src| { - const cc = b.addSystemCommand(&.{ "xcrun", "-sdk", "macosx", "metal" }); - cc.addArgs(flags); - cc.addArg("-c"); - cc.addFileArg(b.path(src)); - cc.addArg("-o"); - link.addFileArg(cc.addOutputFileArg(b.fmt("{s}.air", .{std.fs.path.stem(src)}))); - } - return out; -} +const metallib = metal_build.metallib; /// The Kimi metallib: each source compiled with kimi_dir on the include path, the shared header a dependency. -fn kimiMetallib(b: *std.Build, flags: []const []const u8) std.Build.LazyPath { - const link = b.addSystemCommand(&.{ "xcrun", "-sdk", "macosx", "metallib", "-o" }); - const out = link.addOutputFileArg("kimi.metallib"); - for (kimi_kernels) |name| { - const cc = b.addSystemCommand(&.{ "xcrun", "-sdk", "macosx", "metal" }); - cc.addArgs(flags); - cc.addPrefixedDirectoryArg("-I", b.path(kimi_dir)); - cc.addFileInput(b.path(kimi_dir ++ "/kimi_common.h")); - cc.addArg("-c"); - cc.addFileArg(b.path(b.fmt("{s}/{s}", .{ kimi_dir, name }))); - cc.addArg("-o"); - link.addFileArg(cc.addOutputFileArg(b.fmt("kimi_{s}.air", .{std.fs.path.stem(name)}))); - } - return out; -} +const kimiMetallib = metal_build.kimiMetallib; /// A module whose `bytes` is the metallib at `lib`, embedded at compile time. -fn embedded(b: *std.Build, lib: std.Build.LazyPath, file: []const u8) *std.Build.Module { - const wf = b.addWriteFiles(); - _ = wf.addCopyFile(lib, file); - const src = wf.add("metallib.zig", b.fmt("pub const bytes = @embedFile(\"{s}\");\n", .{file})); - return b.createModule(.{ .root_source_file = src }); -} +const embedded = metal_build.embedded; -/// The page pool's own tests, and its checks under engine_api's paged store and lane host (kv_pool a module of its own). +/// The page pool's own tests, and its checks under engine_api's paged store and lane host (kv_pool a module of its... fn poolChecks(b: *std.Build, target: std.Build.ResolvedTarget, test_step: *std.Build.Step) void { const pool = b.createModule(.{ .root_source_file = b.path("zig/src/core/kv_pool.zig"), .target = target, .optimize = .Debug }); const lanes = b.createModule(.{ .root_source_file = b.path("zig/src/core/lanes/lanes.zig"), .target = target, .optimize = .Debug, .link_libc = true }); @@ -96,6 +59,11 @@ fn poolChecks(b: *std.Build, target: std.Build.ResolvedTarget, test_step: *std.B } pub fn build(b: *std.Build) void { + @import("zig/build/lane_halt_host.zig").steps(b); + @import("zig/build/dsv41_shared.zig").steps(b); + @import("zig/build/dsv41_cuda.zig").steps(b); + @import("zig/build/dsv41_state.zig").steps(b); + @import("zig/build/dsv41_draft.zig").steps(b); const target = b.standardTargetOptions(.{}); @import("zig/build/copy_source.zig").steps(b); const release_version = b.option([]const u8, "version", "Release version, exactly MAJOR.MINOR.PATCH"); @@ -113,6 +81,7 @@ pub fn build(b: *std.Build) void { _ = ids_files.addCopyFile(b.path("zig/src/families/nemotron/draft_ids.txt"), "draft_ids.txt"); const draft_ids = b.createModule(.{ .root_source_file = ids_files.add("draft_ids.zig", "pub const text = @embedFile(\"draft_ids.txt\");\n") }); const test_step = b.step("test", "Unit tests (macOS includes available Metal device checks)"); + @import("zig/build/kernel_symbols.zig").tests(b, test_step); if (b.graph.host.result.os.tag == .macos) { const step5_mods = metalEngineModules(b, b.graph.host, .Debug, draft_ids); const empty = b.addWriteFiles().add("step5-compile-only.metallib", ""); @@ -164,6 +133,10 @@ pub fn build(b: *std.Build) void { @import("zig/build/qwen27_host.zig").tests(b); const xgrammar = grammar_build.option(b); dist_build.targets(b, draft_ids, build_options, release_version, xgrammar); + @import("zig/build/sessions.zig").add(b, test_step); + @import("zig/build/tp.zig").add(b, target, optimize, test_step); + @import("zig/build/gradient_host.zig").add(b); + @import("zig/build/living_tools_host.zig").steps(b); cuda_build.hostTests(b, draft_ids, build_options, test_step, target.result.os.tag != .macos); xpu_build.hostTests(b, test_step); if (target.result.os.tag == .linux or target.result.os.tag == .windows) { @@ -204,445 +177,19 @@ pub fn build(b: *std.Build) void { contract_build.hostTests(b, gemma4_contract_host); sys_build.wire(b); @import("zig/build/checkpoint_config.zig").wire(b); + @import("zig/build/save_guard.zig").wire(b); } -/// `zig build native -Dcpu=apple_m1`: tensorfold-native with the Metal engines for the Python package's bundle (a native M5 build traps on M1-M4). -fn nativeServer(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, metal: *std.Build.Module, engine: *std.Build.Module, api: *std.Build.Module, engines: *std.Build.Module, build_options: *std.Build.Step.Options, test_step: ?*std.Build.Step) *std.Build.Step.Compile { - // the HTTP side keeps its safety checks; the engine below it runs at `optimize` - const tokenizer = engines.import_table.get("tokenizer") orelse b.createModule(.{ .root_source_file = b.path("zig/src/core/tokenizer/tokenizer.zig"), .target = target, .optimize = .ReleaseSafe, .link_libc = true }); - const template = b.createModule(.{ .root_source_file = b.path("zig/src/core/template/template.zig"), .target = target, .optimize = .ReleaseSafe, .link_libc = true }); - const exe = b.addExecutable(.{ .name = "tensorfold-native", .root_module = b.createModule(.{ - .root_source_file = b.path("zig/src/server/main.zig"), - .target = target, - .optimize = .ReleaseSafe, - .link_libc = true, - .imports = &.{ .{ .name = "engine_api", .module = api }, .{ .name = "tokenizer", .module = tokenizer }, .{ .name = "template", .module = template }, .{ .name = "native_engines", .module = engines }, .{ .name = "checkpoint_cli", .module = b.createModule(.{ .root_source_file = b.path("zig/src/cli/cli.zig"), .target = target, .optimize = .ReleaseSafe, .link_libc = true, .imports = &.{ .{ .name = "native_engines", .module = engines }, exlr8_build.cliImport(b, target, .ReleaseSafe, engine), .{ .name = "core", .module = engine } } }) } }, - }) }); - exe.root_module.addOptions("build_options", build_options); - _ = server_build.addTo(b, exe.root_module, target, .ReleaseSafe); - if (test_step == null) { - exe.root_module.strip = true; - return exe; - } - const install = b.addInstallArtifact(exe, .{ .dest_dir = .{ .override = .{ .custom = "native/bin" } } }); - b.step("native", "tensorfold-native with the Metal engines into zig-out/native/bin (bundles: -Dcpu=apple_m1)").dependOn(&install.step); - // libSystem's memcpy, not compiler-rt's: a quad-float or 128-bit helper links compiler-rt, whose weak memcpy then wins - const libc_mem = b.addSystemCommand(&.{ "sh", "-c", "nm -m \"$0\" | grep -q 'external _memcpy (from libSystem)' || { echo 'tensorfold-native links its own memcpy: find the typed std.json int parse or 128-bit float conversion that pulled in compiler-rt'; exit 1; }" }); - libc_mem.addArtifactArg(exe); - test_step.?.dependOn(&libc_mem.step); - const server_tests = b.createModule(.{ - .root_source_file = b.path("zig/src/server/root.zig"), - .target = target, - .optimize = .Debug, // the server's unit tests run with safety checks, as zig_test.sh builds them - .link_libc = true, - .imports = &.{ .{ .name = "engine_api", .module = api }, .{ .name = "tokenizer", .module = tokenizer }, .{ .name = "template", .module = template } }, - }); - // the required-tools tests' 27B mask policy and lane fake, on the engine API's own lanes module - if (api.import_table.get("lanes")) |lanes_mod| { - const q27 = @import("zig/build/qwen27_required.zig").modules(b, lanes_mod); - server_tests.addImport("qwen27_mask_fake", q27.fake); - server_tests.addImport("qwen27_masks", q27.masks); - } - const server_json = server_build.addTo(b, server_tests, target, .Debug); - test_step.?.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = server_tests })).step); - test_step.?.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = server_json })).step); - @import("zig/build/cortheon_fuzz.zig").add(b, target, api, tokenizer, template); - // the parse half's tests, as their own test binary (zig/src/server/tool_parse_test.zig) - const tool_parse_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("zig/src/server/tool_parse_test.zig"), .target = target, .optimize = .Debug, .link_libc = true, .imports = &.{ .{ .name = "engine_api", .module = api }, .{ .name = "tokenizer", .module = tokenizer }, .{ .name = "template", .module = template } } }) }); - _ = server_build.addTo(b, tool_parse_tests.root_module, target, .Debug); - test_step.?.dependOn(&b.addRunArtifact(tool_parse_tests).step); - // the chat-template engine's tests (zig/src/core/template/tests.zig), which no other test binary reaches - test_step.?.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("zig/src/core/template/tests.zig"), .target = target, .optimize = .Debug, .link_libc = true }) })).step); - // the engine seam's own tests (lane_host.zig), as zig/tests/server/zig_test.sh runs them - test_step.?.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = api })).step); - // the frozen server parity goldens, checked by Zig driving fake_serve - golden_build.step(b, target, server_tests, api, tokenizer, template); - b.step("test-native", "The Metal engines module's host-side tests").dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = engines })).step); - const reuse = b.addExecutable(.{ .name = "tf-flashnext-reuse", .root_module = b.createModule(.{ - .root_source_file = b.path("zig/tests/flashnext_reuse.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - .imports = &.{ .{ .name = "metal", .module = metal }, .{ .name = "tensorfold", .module = engine }, .{ .name = "engine_api", .module = api } }, - }) }); - b.installArtifact(reuse); - b.step("tf-flashnext-reuse", "Flash Next prompt reuse against fresh prompt passes: replies at every depth and kept states' bytes").dependOn(&b.addInstallArtifact(reuse, .{}).step); - const batch = b.addExecutable(.{ .name = "tf-flashnext-batch", .root_module = b.createModule(.{ - .root_source_file = b.path("zig/tests/flashnext_batch.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - .imports = &.{ .{ .name = "metal", .module = metal }, .{ .name = "tensorfold", .module = engine }, .{ .name = "kernel_sources", .module = engine.import_table.get("kernel_sources").? } }, - }) }); - b.step("tf-flashnext-batch", "Flash Next shared rounds against fresh legacy replies with prompt admission and cancellation").dependOn(&b.addInstallArtifact(batch, .{}).step); - return exe; -} +/// The native Metal server build is owned by the shared Metal target module. +const nativeServer = metal_build.nativeServer; /// macOS: the metallib, the Metal runtime's test programs and the engine over Metal. -fn metalTargets(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, draft_ids: *std.Build.Module, build_options: *std.Build.Step.Options, test_step: *std.Build.Step) void { - const metal_std = b.option([]const u8, "metal-std", "Metal language version (default: MLX's for this macOS)") orelse mlxMetalStd(b); - - // MLX's mx.fast.metal_kernel JIT: MTLCompileOptions mathMode safe, fp32 functions fast (the default), its language version. - const mlx_flags = [_][]const u8{ b.fmt("-std={s}", .{metal_std}), "-fmetal-math-mode=safe", "-fmetal-math-fp32-functions=fast" }; - const lib = metallib(b, "tensorfold", &kernels, &mlx_flags); - const lib_install = b.addInstallFile(lib, "lib/tensorfold.metallib"); - b.step("metallib", "Compile zig/kernels/metal into tensorfold.metallib").dependOn(&lib_install.step); - - // Control: the same Nemotron kernels under fast math, which the bit-exact check must be able to tell apart. - const fast_flags = [_][]const u8{ b.fmt("-std={s}", .{metal_std}), "-fmetal-math-mode=fast", "-fmetal-math-fp32-functions=fast" }; - const fast_lib = metallib(b, "nemotron_fastmath", generated, &fast_flags); - b.getInstallStep().dependOn(&b.addInstallFile(fast_lib, "lib/nemotron_fastmath.metallib").step); - b.getInstallStep().dependOn(&lib_install.step); - - const mods = metalEngineModules(b, target, optimize, draft_ids); - const metal = mods.metal; - const kernels_mod = embedded(b, lib, "tensorfold.metallib"); - counters_build.targets(b, target, optimize, metal, mods.sources, test_step); - - // The engines registry: the families this platform's binaries serve; the CLI reads it for models/info/pull. - const api = b.createModule(.{ .root_source_file = b.path("zig/src/core/engine_api.zig"), .target = target, .optimize = optimize, .link_libc = true, .imports = &.{.{ .name = "lanes", .module = mods.lanes }} }); - const engines = b.createModule(.{ - .root_source_file = b.path("zig/src/native/metal.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - .imports = &.{ .{ .name = "metal", .module = metal }, .{ .name = "engine_api", .module = api }, .{ .name = "tensorfold", .module = mods.engine } }, - }); - @import("zig/build/drama3_metal.zig").addNative(b, target, optimize, metal, engines, metal_std); - @import("zig/build/drama3_metal.zig").targets(b, target, optimize, metal, metal_std, test_step); - - const programs = [_]struct { name: []const u8, path: []const u8, about: []const u8 }{ - .{ .name = "tf-metal-tests", .path = "zig/tests/gpu_tests.zig", .about = "GPU runtime tests" }, - .{ .name = "tf-dispatch-bench", .path = "zig/tests/dispatch_bench.zig", .about = "Dispatch boundary benchmark" }, - .{ .name = "tf-kernel-exact", .path = "zig/tests/kernel_exact.zig", .about = "Bit-exact check of a kernel against an MLX oracle case" }, - }; - for (programs) |p| { - const mod = b.createModule(.{ - .root_source_file = b.path(p.path), - .target = target, - .optimize = optimize, - .link_libc = true, - .imports = &.{ .{ .name = "metal", .module = metal }, .{ .name = "metallib", .module = kernels_mod } }, - }); - const exe = b.addExecutable(.{ .name = p.name, .root_module = mod }); - b.installArtifact(exe); - b.step(p.name, p.about).dependOn(&b.addInstallArtifact(exe, .{}).step); - } - - // Kimi K3: the family module, its metallib, and its GPU check and benchmark programs. - const kimi_lib = kimiMetallib(b, &mlx_flags); - b.getInstallStep().dependOn(&b.addInstallFile(kimi_lib, "lib/kimi.metallib").step); - const kimi = b.createModule(.{ - .root_source_file = b.path("zig/src/families/kimi_k3/kimi_k3.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - .imports = &.{.{ .name = "metal", .module = metal }}, - }); - const kimi_bytes = embedded(b, kimi_lib, "kimi.metallib"); - const kimi_programs = [_]struct { name: []const u8, path: []const u8, about: []const u8 }{ - .{ .name = "tf-k3-check", .path = "zig/tests/kimi_k3/check.zig", .about = "Kimi K3 kernels and layers on synthetic weights at real shapes" }, - .{ .name = "tf-k3-bench", .path = "zig/tests/kimi_k3/bench.zig", .about = "Kimi K3 kernel bandwidth" }, - }; - for (kimi_programs) |p| { - const mod = b.createModule(.{ - .root_source_file = b.path(p.path), - .target = target, - .optimize = optimize, - .link_libc = true, - .imports = &.{ .{ .name = "metal", .module = metal }, .{ .name = "kimi_k3", .module = kimi }, .{ .name = "kimi_metallib", .module = kimi_bytes } }, - }); - const exe = b.addExecutable(.{ .name = p.name, .root_module = mod }); - b.installArtifact(exe); - b.step(p.name, p.about).dependOn(&b.addInstallArtifact(exe, .{}).step); - } - - // Kimi's tiktoken tokenizer and its parity program (tools/zig/k3_tokenizer_parity.py). - const tiktoken = b.createModule(.{ .root_source_file = b.path("zig/src/core/tokenizer/tiktoken.zig"), .target = target, .optimize = optimize }); - const tok_mod = b.createModule(.{ - .root_source_file = b.path("zig/tests/kimi_k3/tokenizer.zig"), - .target = target, - .optimize = optimize, - .imports = &.{.{ .name = "tiktoken", .module = tiktoken }}, - }); - const tok_exe = b.addExecutable(.{ .name = "tf-k3-tokenizer", .root_module = tok_mod }); - b.installArtifact(tok_exe); - b.step("tf-k3-tokenizer", "Kimi K3's tiktoken tokenizer over parity cases").dependOn(&b.addInstallArtifact(tok_exe, .{}).step); - - // The cluster layer: inventory, membership, planner, loading and converged rounds over the lane core. - const lanes = mods.lanes; - const fabric = mods.fabric; - const cluster = b.createModule(.{ - .root_source_file = b.path("zig/src/cluster/cluster.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - .imports = &.{ .{ .name = "lanes", .module = lanes }, .{ .name = "fabric", .module = fabric } }, - }); - // The engine: kernel sources compiled at run time (MLX's options), the families, the lane core. - const sources = mods.sources; - // Nemotron's packed kernels, compiled offline and embedded: the runtime fallback when this macOS's - // compiler refuses uint4b_format (#451). - const packed_lib = metallib(b, "nemotron_packed", &nemotron_packed, &mlx_flags); - b.getInstallStep().dependOn(&b.addInstallFile(packed_lib, "lib/nemotron_packed.metallib").step); - sources.addImport("nemotron_packed_metallib", embedded(b, packed_lib, "nemotron_packed.metallib")); - const engine = mods.engine; - // FlashNext's CPU contracts, with the family module's imports so the replay graph type-checks - const flash_host = b.addTest(.{ .root_module = b.createModule(.{ - .root_source_file = b.path("zig/flashnext_host.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - .imports = &.{ .{ .name = "metal", .module = metal }, .{ .name = "kernel_sources", .module = sources }, .{ .name = "nemotron_draft_ids", .module = draft_ids }, .{ .name = "lanes", .module = lanes }, .{ .name = "fabric", .module = fabric } }, - }) }); - b.step("compile-flashnext-host", "Compile FlashNext CPU metadata contracts without running them").dependOn(&flash_host.step); - const flash_host_run = b.addRunArtifact(flash_host); - b.step("test-flashnext-host", "Run FlashNext CPU metadata contracts without Metal").dependOn(&flash_host_run.step); - test_step.dependOn(&flash_host_run.step); - // Pack builder cases that do not need the replay graph. - const pack_cases = b.addTest(.{ .root_module = b.createModule(.{ - .root_source_file = b.path("zig/flashnext_pack_cases.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - }) }); - test_step.dependOn(&b.addRunArtifact(pack_cases).step); - const load_admission_cases = b.addTest(.{ .root_module = b.createModule(.{ - .root_source_file = b.path("zig/flashnext_load_admission_cases.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - }) }); - const load_admission_run = b.addRunArtifact(load_admission_cases); - b.step("test-flashnext-load-admission", "CPU-only Flash Next runtime quantization admission").dependOn(&load_admission_run.step); - test_step.dependOn(&load_admission_run.step); - const pack_cache_cases = b.addTest(.{ .root_module = b.createModule(.{ - .root_source_file = b.path("zig/flashnext_pack_cache_cases.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - }) }); - const pack_cache_run = b.addRunArtifact(pack_cache_cases); - b.step("test-flashnext-pack-cache", "CPU-only atomic Flash Next pack cache lifecycle").dependOn(&pack_cache_run.step); - test_step.dependOn(&pack_cache_run.step); - const engine_programs = [_]struct { name: []const u8, path: []const u8, about: []const u8, c_source: ?[]const u8 = null }{ - .{ .name = "tf-qwen35-check", .path = "zig/tests/qwen35_check.zig", .about = "Qwen3.5-2B checkpoint and native operations" }, - .{ .name = "tf-qwen35-forward", .path = "zig/tests/qwen35_forward.zig", .about = "Qwen3.5-2B native teacher-forced logits" }, - .{ .name = "tf-qwen35-exact", .path = "zig/tests/qwen35_exact.zig", .about = "Qwen3.5-2B forward and committed-state exactness" }, - .{ .name = "tf-lfm2-forward", .path = "zig/tests/lfm2_forward.zig", .about = "LFM2-MoE native teacher-forced logits" }, - .{ .name = "tf-lfm2-bench", .path = "zig/tests/lfm2_bench.zig", .about = "LFM2-MoE prompt-chunk and one-row decode timing" }, - .{ .name = "tf-lfm2-exact", .path = "zig/tests/lfm2_exact.zig", .about = "LFM2-MoE prompt chunks, windows and committed-state exactness" }, - .{ .name = "tf-lfm2-fp64", .path = "zig/tests/lfm2_fp64.zig", .about = "LFM2 projection classes and attention on the tensor units, the tiles and the twin, each against float64" }, - .{ .name = "tensorfold", .path = "zig/src/main.zig", .about = "The native engine's command line" }, - .{ .name = "tf-nemotron-fixtures", .path = "zig/tests/nemotron_fixtures.zig", .about = "Nemotron kernels against the Python engine's captured ops" }, - .{ .name = "tf-nemotron-bench", .path = "zig/tests/nemotron_bench.zig", .about = "One-row projection kernels timed by tile count" }, - .{ .name = "tf-nemotron-wide", .path = "zig/tests/nemotron_wide.zig", .about = "Window costs by kernel class and expert overlap of drafted, sibling and stream rows" }, - .{ .name = "tf-nemotron-prefill-check", .path = "zig/tests/nemotron_prefill_check.zig", .about = "A prompt chunk layer by layer against prefill_dump.py's rows" }, - .{ .name = "tf-nemotron-dense", .path = "zig/tests/nemotron_dense.zig", .about = "Dense projection schedules bit-checked against the engine's kernels, then timed" }, - .{ .name = "tf-nemotron-experts", .path = "zig/tests/nemotron_experts.zig", .about = "Routed-expert pass shapes bit-checked against the engine's kernels, then timed by window width" }, - .{ .name = "tf-nemotron-sample-check", .path = "zig/tests/nemotron_sample_check.zig", .about = "tf_sample_full against its host reference on synthetic rows, timed beside tf_gpu_sample" }, - .{ .name = "tf-train-check", .path = "zig/tests/train_check.zig", .about = "The learning step's gradients against the engine's own forward by finite differences, on the real model" }, - .{ .name = "tf-train-pack", .path = "zig/tests/train_pack.zig", .about = "Training passes holding several sequences against one a pass, on the real model: differences and time" }, - .{ .name = "tf-pocket-fit-check", .path = "zig/tests/pocket_fit_check.zig", .about = "A dumped pocket lesson's read rows fitted as the learner fits them, timed, each fit's numbers and weights written" }, - .{ .name = "tf-flashnext-run", .path = "zig/tests/flashnext_run.zig", .about = "Flash Next one-row greedy steps on the Python engine's recorded kernels, against its tokens" }, - .{ .name = "tf-flashnext-pack", .path = "zig/tests/flashnext_pack.zig", .about = "Flash Next's three weight packs built from a checkpoint, byte-compared with a Python dump" }, - .{ .name = "tf-flashnext-think", .path = "zig/tests/flashnext_think.zig", .about = "Flash Next's verdict kernels under thinking budgets on a fake target, against the lane core's close" }, - .{ .name = "tf-grid-sync-bench", .path = "zig/tests/grid_sync_bench.zig", .about = "A GPU-wide barrier in one persistent dispatch against dependent relaunches" }, - .{ .name = "tf-weight-read-check", .path = "zig/tests/weight_read_check.zig", .about = "Check native file reads and failed-read cleanup", .c_source = "zig/tests/pread_fault.c" }, - .{ .name = "tf-glm-run", .path = "zig/tests/glm_run.zig", .about = "GLM-5.3-Flash greedy replies at each draft depth against depth 0 and reference tokens" }, - .{ .name = "tf-glm-vision", .path = "zig/tests/glm_vision.zig", .about = "GLM-5.3-Flash's image tower on an image or a reference dump's patches, compared with the dump" }, - .{ .name = "tf-glm-video", .path = "zig/tests/glm_video.zig", .about = "GLM-5.3-Flash's video preparation on a clip (libtfvideo), written for tools/zig/glm_video_fixtures.py" }, - .{ .name = "tf-qwen-vision", .path = "zig/tests/qwen_vision.zig", .about = "The Qwen-VL tower on picture files: patches, grids and merged rows written for a float32 reference" }, - .{ .name = "tf-qwen-image", .path = "zig/tests/qwen_image.zig", .about = "Qwen-Image-2.1's transformer on a reference case: each step compared, then the schedule timed" }, - .{ .name = "tf-glm-attn-probe", .path = "zig/tests/glm_attn_probe.zig", .about = "GLM-5.3-Flash's dense latent attention kernels on given inputs (scores, probabilities, outputs)" }, - .{ .name = "tf-moe-bench", .path = "zig/tests/moe_bench.zig", .about = "Routed-expert prompt kernels from .metal files on a skewed routing: chained speed, bits against the first" }, - .{ .name = "tf-mm-bench", .path = "zig/tests/mm_bench.zig", .about = "Prompt matmul kernels from .metal files: chained speed at one shape, bits against the first, error against fp64" }, - .{ .name = "tf-gemma4-kernels", .path = "zig/tests/gemma4/kernels.zig", .about = "Gemma 4 dense decode kernels against the reference's dumped fixtures, bit for bit by window width" }, - .{ .name = "tf-gemma4-glue", .path = "zig/tests/gemma4/glue.zig", .about = "Gemma 4 dense glue kernels against the reference's dumped fixtures: embed, norm, q/k/v, attention, tails, softcap" }, - .{ .name = "tf-gemma4-run", .path = "zig/tests/gemma4/run.zig", .about = "Gemma 4 dense greedy decode, tokens and top-two margins for the oracle's check" }, - .{ .name = "tf-gemma4-lanes", .path = "zig/tests/gemma4/lanes.zig", .about = "Gemma 4 dense on the lane core: plain, copy-drafted and shared-round streams against the oracle's tokens" }, - .{ .name = "tf-gemma4-vision", .path = "zig/tests/gemma4/vision.zig", .about = "Gemma 4's image tower against gemma4_vision_fixtures.py's dumps: patches bit for bit, each stage's error beside torch bf16's" }, - .{ .name = "tf-gemma4-bench", .path = "zig/tests/gemma4/bench.zig", .about = "Gemma 4 dense step costs: projections alone by rows against whole forwards" }, - .{ .name = "tf-qwen36-row-seam", .path = "zig/tests/qwen36_row_seam.zig", .about = "Model-free fixture: the row projection's bias sums stay fp32 at every width" }, - }; - for (engine_programs) |p| { - const mod = b.createModule(.{ - .root_source_file = b.path(p.path), - .target = target, - .optimize = optimize, - .link_libc = true, - .imports = &.{ .{ .name = "metal", .module = metal }, .{ .name = "tensorfold", .module = engine }, .{ .name = "core", .module = engine }, .{ .name = "cluster", .module = cluster }, .{ .name = "kernel_sources", .module = sources }, .{ .name = "native_engines", .module = engines }, .{ .name = "engine_api", .module = api }, exlr8_build.cliImport(b, target, optimize, engine) }, - }); - if (p.c_source) |file| { - mod.addCSourceFile(.{ .file = b.path(file), .flags = &.{"-std=c11"} }); - } - const exe = b.addExecutable(.{ .name = p.name, .root_module = mod }); - b.installArtifact(exe); - b.step(p.name, p.about).dependOn(&b.addInstallArtifact(exe, .{}).step); - } - - glm_checks.sampler(b, target, optimize, metal, lanes, sources); - - // MiniMax H3 / FastH3's transformer: the packed rows' denoising forwards against a case file (M5 tensor units). - const h3_kernels = b.createModule(.{ .root_source_file = b.path("zig/kernels/metal/h3/kernels.zig") }); - const h3_safetensors = b.createModule(.{ .root_source_file = b.path("zig/src/core/safetensors.zig"), .target = target, .optimize = optimize }); - const h3_mod = b.createModule(.{ .root_source_file = b.path("zig/src/families/h3/h3.zig"), .target = target, .optimize = optimize, .link_libc = true, .imports = &.{ .{ .name = "metal", .module = metal }, .{ .name = "safetensors", .module = h3_safetensors }, .{ .name = "h3_kernels", .module = h3_kernels }, .{ .name = "kernel_sources", .module = sources } } }); - const h3_dit = b.addExecutable(.{ .name = "tf-h3-dit", .root_module = b.createModule(.{ - .root_source_file = b.path("zig/tests/h3_dit.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - .imports = &.{ .{ .name = "metal", .module = metal }, .{ .name = "h3", .module = h3_mod } }, - }) }); - b.step("tf-h3-dit", "FastH3's Metal transformer against a case file: velocities, every step, timings").dependOn(&b.addInstallArtifact(h3_dit, .{}).step); - // The core row projection (chips without tensor units) on synthetic matrices; runs on any Mac's GPU - const row_mod = b.createModule(.{ .root_source_file = b.path("zig/src/core/row_projection.zig"), .target = target, .optimize = optimize, .link_libc = true, .imports = &.{ .{ .name = "metal", .module = metal }, .{ .name = "kernel_sources", .module = sources } } }); - const row_check = b.addExecutable(.{ .name = "tf-row-check", .root_module = b.createModule(.{ - .root_source_file = b.path("zig/tests/row_projection_check.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - .imports = &.{ .{ .name = "metal", .module = metal }, .{ .name = "row_projection", .module = row_mod } }, - }) }); - b.step("tf-row-check", "The core row projection on synthetic 4-bit matrices: widths bit-equal, a CPU reference, indexed experts").dependOn(&b.addInstallArtifact(row_check, .{}).step); - core_checks.rowWidths(b, target, optimize, metal, row_mod); - test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = row_mod })).step); - core_checks.tileQmm(b, target, optimize, metal, sources, test_step); - exl3_build.targets(b, target, optimize, metal, test_step); - exlr8_build.metalTargets(b, target, optimize, metal, mods.sources, test_step); - exlr8_build.glmTargets(b, target, optimize, metal, mods.engine); - - // The checkpoint CLI: models, info and pull, against a synthetic cache and a fake hub, at ReleaseSafe as it ships. - const cli_tests = b.addTest(.{ .root_module = b.createModule(.{ - .root_source_file = b.path("zig/src/cli/tests.zig"), - .target = target, - .optimize = .ReleaseSafe, - .link_libc = true, - .imports = &.{ .{ .name = "native_engines", .module = engines }, exlr8_build.cliImport(b, target, optimize, mods.engine), .{ .name = "core", .module = mods.engine } }, - }) }); - const run_cli_tests = b.addRunArtifact(cli_tests); - test_step.dependOn(&run_cli_tests.step); - b.step("test-cli", "The checkpoint CLI's host tests").dependOn(&run_cli_tests.step); - - // The core lane, shared-context and Qwen3.8-27B programs and host tests. - qwen27_build.targets(b, target, optimize, metal, sources, engine, lanes, mods.qwen27, test_step); - core_checks.targets(b, target, optimize, metal, engine); - contract_build.metal(b, target, optimize, metal, engine, sources); - glm_checks.targets(b, target, optimize, metal, engine, sources); - flashnext_checks.targets(b, target, optimize, metal, engine); - - _ = nativeServer(b, target, optimize, metal, engine, api, engines, build_options, test_step); - - const cluster_metal = b.createModule(.{ - .root_source_file = b.path("zig/src/cluster/metal_sink.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - .imports = &.{ .{ .name = "metal", .module = metal }, .{ .name = "cluster", .module = cluster } }, - }); - const cluster_exe = b.addExecutable(.{ .name = "tf-cluster", .root_module = b.createModule(.{ - .root_source_file = b.path("zig/src/cluster/main.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - .imports = &.{.{ .name = "cluster", .module = cluster }}, - }) }); - b.installArtifact(cluster_exe); - b.step("tf-cluster", "tensorfold cluster / serve --cluster / node").dependOn(&b.addInstallArtifact(cluster_exe, .{}).step); - // Kimi K3's checkpoint validator: the cluster's header reader cross-checked with the family's own specs. - const k3_validate = b.addExecutable(.{ .name = "tf-k3-validate", .root_module = b.createModule(.{ - .root_source_file = b.path("zig/tests/kimi_k3/validate.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - .imports = &.{ .{ .name = "kimi_k3", .module = kimi }, .{ .name = "cluster", .module = cluster } }, - }) }); - b.installArtifact(k3_validate); - b.step("tf-k3-validate", "Kimi K3's checkpoint: index, headers, coverage and every tensor against the family").dependOn(&b.addInstallArtifact(k3_validate, .{}).step); - const k3_cluster = b.addExecutable(.{ .name = "tf-k3-cluster", .root_module = b.createModule(.{ - .root_source_file = b.path("zig/tests/kimi_k3/cluster.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - .imports = &.{ .{ .name = "metal", .module = metal }, .{ .name = "kimi_k3", .module = kimi }, .{ .name = "kimi_metallib", .module = kimi_bytes }, .{ .name = "cluster", .module = cluster } }, - }) }); - b.installArtifact(k3_cluster); - b.step("tf-k3-cluster", "Kimi K3 tensor- and expert-parallel ranks against one node, through the cluster's canon").dependOn(&b.addInstallArtifact(k3_cluster, .{}).step); - const tp2 = b.addExecutable(.{ .name = "tf-tp2-bench", .root_module = b.createModule(.{ - .root_source_file = b.path("zig/tests/tp2_bench.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - .imports = &.{ .{ .name = "metal", .module = metal }, .{ .name = "fabric", .module = fabric } }, - }) }); - b.step("tf-tp2-bench", "TP=2's exchange as the GPU sees it, between two Macs over MCDMA").dependOn(&b.addInstallArtifact(tp2, .{}).step); - const cluster_tests = b.step("test-cluster", "Cluster tests: fake nodes and fabric, K3 planning (TF_K3_DIR), Metal sink"); - cluster_tests.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = cluster })).step); - cluster_tests.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = cluster_metal })).step); - - // These module tests include Metal device checks when the macOS host has a GPU. - test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = metal })).step); - const helpers = b.createModule(.{ - .root_source_file = b.path("zig/tests/common.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - .imports = &.{ .{ .name = "metal", .module = metal }, .{ .name = "metallib", .module = kernels_mod } }, - }); - test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = helpers })).step); - test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = engine })).step); - test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = lanes })).step); - // the fake fabric maps its windows with mmap: the Macs' RDMA, not Windows - if (target.result.os.tag != .windows) test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = fabric })).step); - const dashboard = b.createModule(.{ .root_source_file = b.path("zig/src/server/dashboard_test.zig"), .target = target, .optimize = optimize, .link_libc = true }); - test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = dashboard })).step); - const kimi_test = b.step("test-k3", "Kimi K3's host-side unit tests (no GPU work)"); - for ([_]*std.Build.Module{ kimi, tiktoken }) |m| { - const run = b.addRunArtifact(b.addTest(.{ .root_module = m })); - kimi_test.dependOn(&run.step); - test_step.dependOn(&run.step); - } -} +const metalTargets = metal_build.metalTargets; /// The server and distribution use the same embedded-source Metal engine. -fn metalEngineModules(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, draft_ids: *std.Build.Module) struct { metal: *std.Build.Module, lanes: *std.Build.Module, fabric: *std.Build.Module, sources: *std.Build.Module, engine: *std.Build.Module, qwen27: qwen27_build.Shared } { - const metal = b.createModule(.{ - .root_source_file = b.path("zig/src/metal/metal.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - }); - metal.linkFramework("Metal", .{}); - metal.linkFramework("Foundation", .{}); - metal.linkFramework("IOSurface", .{}); // the Neural Engine's prompt share passes IOSurfaces - metal.linkSystemLibrary("objc", .{}); - const lanes = b.createModule(.{ .root_source_file = b.path("zig/src/core/lanes/lanes.zig"), .target = target, .optimize = optimize, .link_libc = true }); - const fabric = b.createModule(.{ .root_source_file = b.path("zig/src/fabric/fabric.zig"), .target = target, .optimize = optimize, .link_libc = true }); - const sources = b.createModule(.{ .root_source_file = b.path("zig/kernels/metal/sources.zig") }); - const engine = b.createModule(.{ - .root_source_file = b.path("zig/src/tensorfold.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - .imports = &.{ .{ .name = "metal", .module = metal }, .{ .name = "kernel_sources", .module = sources }, .{ .name = "nemotron_draft_ids", .module = draft_ids }, .{ .name = "lanes", .module = lanes }, .{ .name = "fabric", .module = fabric } }, - }); - return .{ .metal = metal, .lanes = lanes, .fabric = fabric, .sources = sources, .engine = engine, .qwen27 = qwen27_build.engineModules(b, target, metal, sources, engine) }; -} +const metalEngineModules = metal_build.metalEngineModules; -pub fn distMetalServer(b: *std.Build, target: std.Build.ResolvedTarget, draft_ids: *std.Build.Module, build_options: *std.Build.Step.Options, sdk: []const u8) *std.Build.Step.Compile { - const m = metalEngineModules(b, target, .fast, draft_ids); - // the release binary embeds the same prebuilt packed-kernel metallib as the development build - const flags = [_][]const u8{ b.fmt("-std={s}", .{mlxMetalStd(b)}), "-fmetal-math-mode=safe", "-fmetal-math-fp32-functions=fast" }; - m.sources.addImport("nemotron_packed_metallib", embedded(b, metallib(b, "nemotron_packed", &nemotron_packed, &flags), "nemotron_packed.metallib")); - // An explicit deployment target is a cross-build even on macOS. Supply the SDK search paths. - m.metal.addSystemFrameworkPath(b.graph.cwdRelativePath(b.pathJoin(&.{ sdk, "System/Library/Frameworks" }))); - m.metal.addLibraryPath(b.graph.cwdRelativePath(b.pathJoin(&.{ sdk, "usr/lib" }))); - m.metal.addSystemIncludePath(b.graph.cwdRelativePath(b.pathJoin(&.{ sdk, "usr/include" }))); - const api = b.createModule(.{ .root_source_file = b.path("zig/src/core/engine_api.zig"), .target = target, .optimize = .fast, .link_libc = true, .imports = &.{.{ .name = "lanes", .module = m.lanes }} }); - const engines = b.createModule(.{ .root_source_file = b.path("zig/src/native/metal.zig"), .target = target, .optimize = .fast, .link_libc = true, .imports = &.{ .{ .name = "metal", .module = m.metal }, .{ .name = "engine_api", .module = api }, .{ .name = "tensorfold", .module = m.engine } } }); - @import("zig/build/drama3_metal.zig").addNative(b, target, .fast, m.metal, engines, mlxMetalStd(b)); - return nativeServer(b, target, .fast, m.metal, m.engine, api, engines, build_options, null); -} +pub const distMetalServer = metal_build.distMetalServer; test { _ = dist_build; diff --git a/build.zig.zon b/build.zig.zon index dbcb0ed4d6..187350c97e 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .tensorfold, - .version = "1.0.12", + .version = "1.1.1", .fingerprint = 0xb6dc5a482d53980a, // Changing this has security and trust implications. .minimum_zig_version = "0.17.0", .dependencies = .{}, diff --git a/docs/api.md b/docs/api.md index 97dd8f7f9c..11793d2ef2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -5,42 +5,59 @@ The base URL is `http://127.0.0.1:8080/v1` with the default server settings. ## Native chat template override `tensorfold serve MODEL --chat-template custom-chat.jinja` loads a UTF-8 Jinja template from that file -instead of the checkpoint's template files or `tokenizer_config.json` templates. Relative paths resolve -from the server's working directory. The file is read once at startup, with the same 16 MiB limit as -the checkpoint's `chat_template.jinja`. +instead of the checkpoint's template files or `tokenizer_config.json` templates. Relative paths resolve from the server's working directory. The file is read once at startup, with the same 16 MiB limit as the checkpoint's `chat_template.jinja`. -The override handles both ordinary and tool-bearing chat requests; it does not retain a checkpoint's -separate `tool_use` template. The checkpoint's tokenizer, special tokens and EOS IDs stay unchanged. -Unreadable or malformed templates fail startup before the engine opens, without falling back to the -checkpoint template. An empty file leaves chat rendering unavailable, as a checkpoint with no template does. -Raw completion prompts do not use the chat template. +The override handles both ordinary and tool-bearing chat requests; it does not retain a checkpoint's separate `tool_use` template. The checkpoint's tokenizer, special tokens and EOS IDs stay unchanged. Unreadable or malformed templates fail startup before the engine opens, without falling back to the checkpoint template. An empty file leaves chat rendering unavailable, as a checkpoint with no template does. Raw completion prompts do not use the chat template. ## API keys -Both servers accept repeated `--api-key KEY`, comma-separated `TENSORFOLD_API_KEY`, and `--api-key-file PATH`. -The sources add keys together. A file has one key per line, optional `label: key` entries, and lines starting with +Both servers accept repeated `--api-key KEY`, comma-separated `TENSORFOLD_API_KEY`, and `--api-key-file PATH`. The sources add keys together. A file has one key per line, optional `label: key` entries, and lines starting with `#` for comments. Set its permissions to `0600`; startup refuses a file readable by other users. The server stores SHA-256 digests and compares every configured digest for each authentication attempt. -Send `Authorization: Bearer KEY` from OpenAI-compatible clients, or `x-api-key: KEY` from Messages clients. -Missing or invalid credentials return HTTP 401 with `WWW-Authenticate: Bearer` and the route's error format. -All `/v1/*`, tokenization, metrics and their inference aliases require a key; `--metrics-open` opens both metrics -routes. `/health` remains open and returns only `{"status":"ok"}` when keys are configured. -With no keys, the existing open routes and health details stay unchanged; a non-loopback bind prints a warning. +Send `Authorization: Bearer KEY` from OpenAI-compatible clients, or `x-api-key: KEY` from Messages clients. Missing or invalid credentials return HTTP 401 with `WWW-Authenticate: Bearer` and the route's error format. With keys configured, the native server requires a key on every route by default. Its public exceptions are GET `/`, `/health`, `/slide` and the eight embedded `/slide/` scripts; `--metrics-open` also opens GET `/metrics` and `/v1/metrics`. Other methods and unknown assets require a key. This includes every `/systemone` inference alias and future private routes. `/health` remains open and returns only `{"status":"ok"}` when keys are configured. Public health is read-only, including when a reset query is supplied. With no keys, its health details remain available; a non-loopback bind prints a warning. -The native server raises its soft open-file limit before listening when the platform permits it. If `accept` -runs out of descriptors, it logs one guidance line per burst and keeps a short retry backoff. +The native server raises its soft open-file limit before listening when the platform permits it. If `accept` runs out of descriptors, it logs one guidance line per burst and keeps a short retry backoff. -Replace the key file atomically to rotate keys. Its modification time is checked at most once a second; SIGHUP -requests an immediate reload. An empty, unreadable or malformed replacement closes authenticated routes until a -valid file returns. Request logs and `tensorfold:requests_total` use labels, never keys or digests. -Unnamed keys receive `cli-N`, `env-N` or `file-N` labels. Labels contain at most 64 letters, digits, dots, -underscores or hyphens; choose non-secret labels. +Replace the key file atomically to rotate keys. Its modification time is checked at most once a second; SIGHUP requests an immediate reload. An empty, unreadable or malformed replacement closes authenticated routes until a valid file returns. Request logs and `tensorfold:requests_total` use labels, never keys or digests. Unnamed keys receive `cli-N`, `env-N` or `file-N` labels. Labels contain at most 64 letters, digits, dots, underscores or hyphens; choose non-secret labels. -In the Python 0.6.6 engine's [control room](control.md), `tensorfold service install MODEL --api-key-file PATH` -forwards the file to its server, and `tensorfold tui --url URL --token-env VARIABLE` sends that variable's value as a -bearer key. The native binary has neither command. -Prefer a restricted file over command-line keys, which can appear in the operating system's process list. +In the Python 0.6.6 engine's [control room](control.md), `tensorfold service install MODEL --api-key-file PATH` forwards the file to its server, and `tensorfold tui --url URL --token-env VARIABLE` sends that variable's value as a bearer key. The native binary has neither command. Prefer a restricted file over command-line keys, which can appear in the operating system's process list. + + +## Browser request headers + +The server accepts origin-form targets beginning with `/`, with query text and trailing slashes interpreted by the +shared route helper. Absolute URLs, fragments and control bytes are refused before routing. Routes are case-sensitive +and percent escapes are not decoded into aliases. Malformed or folded header fields are refused and closed. + +On a loopback bind, the native server accepts a `Host` naming `localhost` or a loopback IP, with any valid port. +It checks the actual bound socket, including a hostname that resolved to loopback. Missing, duplicate, malformed or +foreign hosts receive HTTP 403 before authentication, `100 Continue` or routing. Repeat `--allowed-host NAME` to add +trusted hostnames or bracketed IP literals. A name has no scheme, path or port; it admits that name at any port. +The flag changes header admission, not the listening address. A non-loopback bind skips this host restriction. + +On every bind, an `Origin` must match the request's `Host` and the native server's HTTP scheme. Hostname case, +IPv6 spellings and default ports are normalized. The origin must be a single `http://HOST[:PORT]` or +`https://HOST[:PORT]` value with no path or userinfo. `null`, malformed values and duplicate origins are refused. +Repeat `--allowed-origin ORIGIN` for trusted exceptions, including an HTTPS origin used through a TLS proxy. +Host and origin exceptions are independent; allowing an origin does not admit a foreign loopback Host. +Forwarded headers do not select the scheme or bypass either check. + +Each 403 names the refused header and its corresponding exception flag. Clients without an `Origin`, including curl, +SDKs and the app, keep their existing requests with a valid Host. Content-Type is not required by these checks, so +`curl -d` requests keep working. The server adds no CORS headers; these flags do not enable cross-origin browser reads. +The header checks protect browser access and do not replace API keys. Trailing spaces or tabs in Host or Origin values +are refused; clients should omit that outer whitespace. + +Host and Origin exception lists each accept at most 64 entries. DNS names use nonempty ASCII labels without a trailing +dot; IPv6 literals are bracketed without zone identifiers. Every HTTP request needs exactly one valid Host, including +HTTP/1.0; hostless HTTP/0.9 is refused and closed. An external bind skips only loopback-name admission. + +For a trusted proxy name and web origin, for example: + +```sh +tensorfold-native serve MODEL --allowed-host inference.example --allowed-origin https://inference.example +``` | Route | Behavior | | --- | --- | @@ -101,7 +118,7 @@ each route and its events. | Route | What it does | | --- | --- | -| `POST /v1/slide/learn` | `{"text": "..."}`: learns the facts in the text, streaming `fact`, `learning` and `learned` events | +| `POST /v1/slide/learn` | `{"text": "...", "questions": ["..."]}`: learns the facts in the text, streaming `fact`, `learning` and `learned` events; `questions` is optional (see Living Weights) | | `GET /v1/slide/graph` | Every fact as JSON: its state, neurons, questions and their expected answers, and the model's pocket | | `DELETE /v1/slide/graph` | Clears the graph; the weights keep what they learned | | `DELETE /v1/slide/fact/{id}` | Deletes one fact from the weights, every other fact checked again, streaming `fact`, `checking`, `zeroed`, `checked` and `forgot` events; an optional `{"reason": "..."}` goes into the graph | @@ -121,7 +138,7 @@ delete runs gets 503, an id that is not a number 400, and an id the graph does n | `parallel_tool_calls` | False returns at most one completed call | Both | | `max_tokens`, `max_completion_tokens` | Explicit reply limit; rejected if prompt plus reply exceeds the window | Both | | `temperature`, `top_p`, `top_k`, `min_p` | Sampling overrides; zero temperature is greedy | Both | -| `seed` | Sampling key; otherwise derived from the prompt (and `TENSORFOLD_SEED_SALT`) | Both | +| `seed` | Explicit sampling key; native fallback uses configured defaults, then a fresh key (see below) | Both | | `stream` | Server-sent events; the last event carries usage | Both | | `stream_options.include_usage` | Usage in its own final event with `"choices": []`, not on the finish event | Both | | `chat_template_kwargs.enable_thinking` | Template thinking toggle; `chat_template_kwargs.thinking` (`true`/`false` or `{"type": "enabled"}`/`{"type": "disabled"}`, as DeepSeek-V4 clients send it) is read the same way when `enable_thinking` is absent; other values are ignored | Both | @@ -148,7 +165,8 @@ sequences. `/v1/completions` takes the legacy `logprobs`, an integer from 0 thro the legacy form, streamed or not: `tokens`, `token_logprobs`, `top_logprobs` (each alternative's text to its logprob) and `text_offset` (each token's first character in the completion, counting each token's own text). Other engines, the Responses route and the request modes above return HTTP 400 when probabilities are requested. -Nonstreamed `/v1/completions` also takes vLLM's `prompt_logprobs`, an integer from 0 through 20, on the same engine. +Nonstreamed `/v1/completions` also takes vLLM's `prompt_logprobs`, an integer from 0 through 20, on the same engine +and on GLM-5.3-Flash's CUDA engine. `choices[0].prompt_logprobs` has one entry per prompt token: `null` for the first, then an object keyed by token id (a string) whose first key is the prompt token itself, followed by the requested best tokens; each value has `logprob`, `rank` and `decoded_token`. The prompt token's `rank` counts every vocabulary token at or above it, @@ -276,7 +294,8 @@ Responses uses `{"type":"function","name":"lookup"}`. Only offered tools are all call with no tools gets HTTP 400. `auto` remains optional, and `none` hides the tools. The constrained answer contains one or more complete calls and no prose outside them. Arguments follow the offered -parameter schema. Qwen XML and JSON call templates keep their envelopes. Gemma 4 uses +parameter schema. GLM uses `` and `` tags after the function name. +Qwen XML and JSON call templates keep their envelopes. Gemma 4 uses `<|tool_call>call:NAME{...}` with JSON arguments, which the tool parser accepts. An unrecognized template uses the canonical `{"name":"NAME","arguments":{...}}` form. The native grammar limits whitespace between calls; `parallel_tool_calls: false` allows exactly one. With thinking enabled, the constraint @@ -284,15 +303,17 @@ starts after the think-end token. A reply cut short by `max_tokens` can remain i Drafting stays enabled and uses the same grammar masks as plain decoding. This requires the loaded backend's structured-output capability and `libtfgrammar`. Nemotron, Qwen3.5-2B, -Qwen3.8-27B (`qwen27`), Gemma 4 31B, Step-5 and LFM2 MoE on Metal have that capability; Qwen27 on Intel also has it. +Qwen3.8-27B (`qwen27`), Gemma 4 31B, Step-5, LFM2 MoE and Flash Next shared sessions on Metal have that capability; Qwen27 on Intel also has it. Qwen3.8-27B applies the grammar to its first draw, each verified row and each branch of its draft proposals. Structured requests use its shared lane core, including when the ordinary lone-request serial optimization is enabled. -CUDA determines support from its loaded backend's mask hook. Flash Next Metal (`flashnext-zig`) and GLM Metal -(`glm-zig`, `glm-dsa-zig`) lack that hook and refuse required/named choices before generation with HTTP 400, for example: -`backend 'flashnext-zig' cannot enforce tool_choice "required" or a named function: it has no token grammar support; send "auto" or "none"`. +CUDA determines support from its loaded backend's mask hook. GLM Metal +(`glm-zig`, `glm-dsa-zig`) lacks that hook and refuse required/named choices before generation with HTTP 400, for example: +`backend 'glm-zig' cannot enforce tool_choice "required" or a named function: it has no token grammar support; send "auto" or "none"`. The server does not silently turn a required call into `auto`. Installing the grammar library does not add a missing backend mask hook. A missing library or grammar compilation error is also refused before generation with its reason. +The native GLM CUDA backend also supports token grammars, gathering vocabulary rows before rank 0 masks and draws. + HTTP 400 comes before any token for a malformed field, a grammar xgrammar cannot compile (with xgrammar's reason), a server without xgrammar (with the install command; on the native server, a server that cannot load `libtfgrammar`), a grammar sent with `tool_choice: "required"` or a named function, and `logprobs` with a grammar or tools. A reply whose @@ -419,10 +440,12 @@ For exactness comparisons, hold the checkpoint, template, runtime, prompt, seed constant, then compare the decoded reply with `draft` enabled and disabled. Repeat with fresh and reused prefixes, and compare each MLX concurrent request with its solo run. -A request without `seed` takes one derived from its prompt, so running the same evaluation twice against one server -repeats the same samples wherever the conversations agree (an agent benchmark's second pass then mostly replays its -first). To draw independent repeats, send a `seed` per run, or start each run's server with a different -`TENSORFOLD_SEED_SALT` (an integer mixed into every prompt-derived seed; 0, the default, keeps today's seeds). +Native sampled requests use the request seed, then an injected configured default, then a fresh 63-bit OS key. No stock +serve flag or generation config supplies a default seed. The done line appends `seed=` after +its request timing and tok/s fields, for given and drawn keys. Send that seed again to reproduce a draw. Greedy requests draw +no key. Native `serve --prompt-seed` keeps historical prompt keying, including `TENSORFOLD_SEED_SALT`; CLI runs and +speech keep their existing prompt keying. Always give comparison requests the same explicit seed, including release +cells, drafted/plain, cached/fresh and concurrent/solo checks. See [request seeds](request-seeds.md). ## Metrics @@ -523,8 +546,8 @@ configured the body is only `{"status": "ok"}`. Without keys it has these fields `live.connections` counts running and waiting requests, and `live.waiting` the waiting ones. `decode_tokens_per_second` is the tokens decoded in the last 2 s, divided by 2. `prefill_tokens_per_second` is the last prompt pass's uncached tokens over its time, shown for 2 s after the pass ends and 0 otherwise. Both rates are -rounded to one decimal. `?reset_peak=1` starts a new CUDA peak at the current `active`; `/memory`'s `device.peak_bytes` -is the same peak. With keys configured, `reset_peak` is ignored. +rounded to one decimal. Public health never resets the peak, with or without keys. Reset queries on `/health` and `/` +are ignored. The separate `/memory` route also reports memory without resetting it and requires a key when keys are configured. The Python 0.6.6 server's body differs: its MLX `memory` can add `budget`, `mlx_budget` and `footprint`, and its CUDA server sends the counters described under [Responses](#responses). For memory figures on a Mac, use diff --git a/docs/community-benchmarks.md b/docs/community-benchmarks.md index 87a242cdee..0e3c106b74 100644 --- a/docs/community-benchmarks.md +++ b/docs/community-benchmarks.md @@ -11,7 +11,7 @@ The examples call the command `tensorfold`. A source build's binary is `zig-out/ The command starts its own single-request `serve` on a loopback port, disables prompt reuse, warms each workload, and measures five repeats at sampled and greedy settings. It stops only the server it started, and a Ctrl-C or a closed terminal stops that server too; a second Ctrl-C exits at once and kills it. The default output limit is 256 tokens. The checkpoint must already be in the cache: `tensorfold benchmark` never downloads, so run `tensorfold pull` first. `--drafter DIR_OR_REPO` passes a drafter to engines that take one. Drafts stay on by default, and only `--serial` disables them. The default `--drafter auto` passes the target's own drafter when it is already cached (`z-lab/Qwen3.8-27B-DFlash2` for Qwen3.8-27B) and names it in the run output and the receipt; when it is not cached, the run passes none and says why. `--drafter none` passes none. When `--serial` overrides a given `--drafter`, the run prints a line saying so. -An engine that decodes greedily only refuses the sampled cells, so it cannot complete community-v1; the run prints the server's reason, and `--temperatures 0` gives a local run. A large checkpoint may need its engine's own load limit raised (`GLM_LOAD_LIMIT_GB` for GLM-5.3-Flash) and a longer `--startup-timeout`: without the flag, the run waits up to 600 s plus 6 s per GB of weights past 20 GB, at most 3600 s. +An engine that decodes greedily only refuses the sampled cells, so it cannot complete community-v2; the run prints the server's reason, and `--temperatures 0` gives a local run. A large checkpoint may need its engine's own load limit raised (`GLM_LOAD_LIMIT_GB` for GLM-5.3-Flash) and a longer `--startup-timeout`: without the flag, the run waits up to 600 s plus 6 s per GB of weights past 20 GB, at most 3600 s. Results are saved locally in `~/.tensorfold/benchmarks/RUN_ID.json`; `--output FILE` chooses another location. Files contain public hardware and model metadata and individual attempts, never prompt text, generated output, machine names, serial numbers, GPU UUIDs, addresses or local model paths. Local model directories can be benchmarked but cannot be published, and a drafter directory is never named. Managed native runs record `python_version` as `"none"`. An attached manifest may attest the same value; an unattested attached run records `"unknown"`. @@ -21,7 +21,7 @@ Results are saved locally in `~/.tensorfold/benchmarks/RUN_ID.json`; `--output F # Inspect a saved receipt without uploading it. tensorfold benchmark inspect receipt.json -# Show the immutable community-v1 fixture and settings manifest. +# Show the immutable community-v2 fixture and settings manifest. tensorfold benchmark suites # A short custom run, outside the standard five-repeat comparison. @@ -35,7 +35,7 @@ tensorfold benchmark example-org/public-checkpoint --server http://127.0.0.1:808 Attached-server results do not claim the client's hardware is the server's hardware. The client reads `GET /v1/benchmark/manifest` from the server it attached to. A manifest that passes the receipt checks fills the version, hardware, checkpoint hashes, revision and rank count (1 or 2). Without that document, or if any field fails those checks, the receipt keeps unknown hardware and an unknown revision and remains unranked. An attached run records the backend, model and context you declare, unchecked against the server. The runner does not clear or restart an attached server's caches. -The runner records failures, incomplete streams, missing usage, early EOS and cancellations. Exit code 0 means every measured attempt completed its requested output with a delivery interval; 1 means an error, and with `--publish` the receipt is still saved when the run finished; 2 means incomplete attempts remain in the saved receipt; 130 means the run was cancelled with Ctrl-C: an attempt in flight is kept in the receipt, and a cancel during server startup saves nothing. SIGTERM and SIGHUP exit 143 and 129 without a receipt. Errors never silently disappear from an average. +The runner records failures, incomplete streams, missing usage, early EOS and cancellations. Exit code 0 means every measured attempt completed under its protocol with a delivery interval; 1 means an error; v2 `--publish` is refused before a run starts and creates no receipt; 2 means incomplete attempts remain in the saved receipt; 130 means the run was cancelled with Ctrl-C: an attempt in flight is kept in the receipt, and a cancel during server startup saves nothing. SIGTERM and SIGHUP exit 143 and 129 without a receipt. Errors never silently disappear from an average. ## What the rates mean @@ -43,19 +43,35 @@ The runner records failures, incomplete streams, missing usage, early EOS and ca **Server-reported tok/s** is kept separately when the server supplies it and is never substituted for stream delivery. First-text latency, request duration and server-reported prefill time remain separate measurements. -The suite fixes prompts, seeds, sampled top-k and top-p, and thinking settings. Successful warm-ups are excluded; failed warm-ups are kept and make their cells incomplete. A cell has aggregates only when every declared repetition succeeds. Early EOS is visible and excluded from full-length comparisons. +The suite fixes prompts, seeds, sampled top-k and top-p, and thinking settings. Successful warm-ups are excluded; failed warm-ups are kept and make their cells incomplete. A cell has aggregates only when every declared repetition succeeds. In community-v2, a sampled reply that naturally stops after at least 128 tokens is complete. Its `finish_reason` is recorded, and its rate uses the actual completion count, never the requested budget. Greedy replies still need the full requested count. Shorter replies, premature length stops and unmeasured delivery remain incomplete; empty output fails. Receipts include token hashes supplied by the server, but the command does not run paired serial and drafted equality tests. A token hash alone is not an exactness proof. +## Protocol versions + +New runs use `community-v2` and its manifest digest. Existing `community-v1` receipts keep their original +full-output rule and remain readable; inspection never upgrades them. Every ranking must select exactly +one suite ID and digest, including contributor speed standings and profiles. A receiver must validate the +version's sample fields and completion rule before computing that version's aggregates. It must reject an +unknown or mismatched digest, never relabel it or merge v1 and v2 medians. Deploy the receiver's v2 support +before accepting v2 uploads; an older v1-only receiver will reject them. The receiver is maintained separately +from this source tree. V2 submissions are disabled in this release, including `--publish`, saved-receipt +publication and direct uploads. Local runs and inspection remain available. The lead enables v2 only +once both the client and receiver have shipped support, including separate rankings. + +Thanks to @BlivionIaG and @easthambuilds for the sampled-stop reports and proposal. + ## Publishing -The browser upload flow needs no login, pasted token, email request or administrator approval. Install or update the CLI using the [runbook](../RUNBOOK.md), run a cached public model locally, and save its receipt: +Community-v2 runs stay local until the receiver accepts v2 and separates its rankings. Both `publish` and a run with `--publish` refuse v2 before a run, credential lookup, public-checkpoint lookup or upload. Local run, save and inspect remain available. + +The browser upload flow remains available for eligible community-v1 receipts, without a login, pasted token, email request or administrator approval. Install or update the CLI using the [runbook](../RUNBOOK.md). New runs save v2 locally: ```sh -tensorfold benchmark TensorFold/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-MLX-4bit --output benchmark.json +tensorfold benchmark TensorFold/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-MLX-4bit --output benchmark-v2.json ``` -Open [the benchmark board](https://tensorfold.dev/benchmarks), choose **Upload result**, enter a public display name and select `benchmark.json`. Review the hardware, model and timing fields, then confirm the data to submit it. Receipt contents are not sent before confirmation. The website creates a scoped browser profile privately and remembers it across reloads using a Secure, HttpOnly, SameSite cookie; there is no upload key to copy or paste. +For an existing eligible community-v1 receipt, open [the benchmark board](https://tensorfold.dev/benchmarks), choose **Upload result**, enter a public display name and select `legacy-v1-receipt.json`. Review the hardware, model and timing fields, then confirm the data to submit it. Receipt contents are not sent before confirmation. The website creates a scoped browser profile privately and remembers it across reloads using a Secure, HttpOnly, SameSite cookie; there is no upload key to copy or paste. That browser profile keeps later submissions together, with up to 20 uploads per day. Clearing browser data or using another browser creates a separate profile. Display names are self-chosen and do not verify an identity; matching names do not merge profiles. New-profile limits apply to reduce spam. @@ -67,14 +83,11 @@ Retries return the same saved submission without using another upload. Older man ### Existing CLI publishing credentials -CLI installations that already have scoped upload credentials can continue to publish directly: +CLI installations that already have scoped upload credentials can continue to publish eligible community-v1 receipts directly: ```sh -# Run, show the public fields, and ask before publishing. -tensorfold benchmark TensorFold/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-MLX-4bit --publish - -# Publish an existing receipt; --yes consents without the prompt. -tensorfold benchmark publish receipt.json --yes +# Publish an eligible legacy receipt; --yes consents without the prompt. +tensorfold benchmark publish legacy-v1-receipt.json --yes # Remove locally stored CLI upload credentials. tensorfold benchmark logout @@ -107,3 +120,24 @@ The parameter-versus-speed plot uses the selected parameter basis on a logarithm ## AMD (ROCm) A ROCm build runs the same suite and saves its receipt locally, with backend `rocm` on platform `linux`. `--backend auto` picks `rocm` on that build; an attached run names it with `--backend rocm`. A managed ROCm run records the CPU and system memory, while GPU names and the driver version stay unknown. `inspect` shows a ROCm receipt as usual. The board accepts mlx and cuda receipts for now, so `--publish` with ROCm is refused before the run starts and `benchmark publish` refuses a ROCm receipt before any upload; publishing waits for the board to accept rocm. + + +## Chat-only checkpoints in community-v2 + +When a native family declares `raw_text = false`, the code workload sends its unchanged prompt text as one user +message to `/v1/chat/completions`. Native runs use the matched family declaration from the admitted config, not a +model-name guess. Families that accept raw text keep `/v1/completions` for code. + +For an attached server, declare this explicitly: + +```sh +tensorfold benchmark owner/model --server http://127.0.0.1:8080 --backend cuda --chat-only +``` + +Use `--chat-only` only when the checkpoint has no raw-text mode. It applies to attached v2 runs. Without it, code +keeps the completion route. Empty replies always fail; the client never infers a capability or retries through chat +from an empty response. Each v2 sample records its API route, including failures and retained warmups. The code +prompt, seeds, token budget and temperature are unchanged. The ordinary chat workload still uses the chat route. + +The v2 manifest digest includes this routing rule. V1's manifest, raw code fixture and receipt fields are unchanged. +V2 publication remains disabled until the receiver accepts the updated protocol and separates its rankings. diff --git a/docs/credits.md b/docs/credits.md index bf56fb06cb..f67ff5f41f 100644 --- a/docs/credits.md +++ b/docs/credits.md @@ -226,3 +226,5 @@ credited here. [#289](https://github.com/ashhart/TensorFold/pull/289)), @taussoe ([#66](https://github.com/ashhart/TensorFold/pull/66)), @jasontitus ([#33](https://github.com/ashhart/TensorFold/pull/33)), @feni6 ([#120](https://github.com/ashhart/TensorFold/pull/120)). + +- @tpischke requested fresh per-request sampling keys and reproducible done-line seeds in [#1026](https://github.com/ashhart/TensorFold/issues/1026). diff --git a/docs/csa2-reference-cases.md b/docs/csa2-reference-cases.md new file mode 100644 index 0000000000..05a5449b21 --- /dev/null +++ b/docs/csa2-reference-cases.md @@ -0,0 +1,41 @@ +# CSA2 sink and output-mix reference cases + +`zig/tests/deepseek_v41_reference/operator_csa2_fp64_sink.zig` compares the +Metal sparse-attention output with the FP64 equation. The cases below preserve +the test's fixed seeds, shapes and tolerances. + +## Sink normalization + +One live column fixes the running maximum at one score. Changing the sink +therefore isolates the `sink - max` normalization term. The sweep includes +underflow, balanced mass and overflow boundaries of FP32 exponentiation. +Near overflow, a one-ULP change in the maximum can determine whether the +denominator remains finite; the corresponding output can be near `1e-38`. +The exact boundary depends on the generated score, not just the sink value. + +The per-head case assigns sink values `-36`, `-12`, `12` and `36`. These +different masses check both head indexing and normalization arithmetic. + +## Online tile denominator + +The equal-score case spans ten tiles and 639 terms. Equal exponentials make +the unsunk denominator equal to the number of terms. Ramped scores exercise +the online correction as earlier mass falls toward FP32 underflow. Descending +scores keep the running maximum fixed and remove that rescaling step. The +single-tile case applies no inter-tile correction. + +## Output mix and cancellation + +Window and compressed rows share an accumulator but use different scale grids. +The dominance cases reverse their relative magnitudes across six orders of +magnitude; a third case balances their scales. + +The mirror cases use 128 window rows with a large shape whose sign alternates +across columns and rows. A uniform query has zero dot product with that shape. +The large contributions also cancel in the weighted sum, leaving a small shared +perturbation. This exposes error that would be hidden by a large final result. +Shape exponents are 8, 16 and 23; the shared perturbation is scaled by `2^-16`. +Smaller perturbations can disappear in the FP8 representation itself. + +These are operator comparisons. Passing them does not establish whole-model +logit parity, distributed state correctness or throughput. diff --git a/docs/deepseek-execution.md b/docs/deepseek-execution.md new file mode 100644 index 0000000000..6b5a7a04fa --- /dev/null +++ b/docs/deepseek-execution.md @@ -0,0 +1,70 @@ +# DeepSeek execution contracts + +The served EXL3 product assigns two columns and eight rows to a SIMD group. +Lanes 0 through 15 and 16 through 31 own the two columns. A lane decodes its tile state and +shuffles it to the lanes that sum that column. A one-row product uses eight +times as many groups as `tf_exl3_matmul_impl`; each row/column retains its +products and TwoSum steps in K order. The kept-dense product instead assigns +one output element per thread and accumulates BF16 weights in FP32, in +sequential K order, into the region consumed by output rotation. + +## Concurrent groups and dispatch thresholds + +Attention can group `wq_a`, `wkv` and cache-owner compressor `wkv`/`wgate`. +They read only the input and write separate outputs. A kept-dense compressor +repack declines this group without encoding work, allowing the caller to run +the individual products. Diagnostics also retain the individual path. + +The concurrent forward uses barriers after other dispatches to preserve serial +ordering. `TF_DSV41_SERIAL=1` or an installed profiler selects the serial +encoder. Grouped projection jobs receive separate consecutive scratch slices; +a group is flushed before the next job would exceed capacity. + +For up to four rows, attention indexes all eight slices in one product: row +`8*t+g` represents token `t`, group `g`, at the individual products' offsets. +Indexed rows decode tiles independently. With more rows, the per-slice path +shares each decoded tile across eight rows. + +The following M5 Max timings are inherited tuning observations, not M3 Ultra +qualification or measurements from the TP2 run: + +| Attention rows | Indexed slices | Separate slices | +| --- | --- | --- | +| 4 | 1.0 ms | 1.5 ms | +| 8 | 1.9 ms | 1.6 ms | +| 256 | 58 ms | 17 ms | + +The shared expert's merged row also decodes alone, while its separate product +shares tiles across rows. Inherited per-layer M5 Max observations were +2.81 → 2.23 ms for one row, 4.20 → 4.03 ms for two and 7.23 → 7.29 ms for +four. The implementation merges only one or two rows. `work.moeSlot` preserves +routed row positions and places shared rows below their block, so SwiGLU and +combine use the same values as the separate shared path. + +## Streamed Engram and lanes + +Engram tables remain in their safetensors shards. Each chunk deduplicates row +IDs, reads misses through uncached preads on worker threads and compacts the +rows into staging. A bounded row cache can supply hits. The unchanged gather +kernel consumes the same row bytes as a resident table. + +`Fetcher.fetch` receives all misses for one stage call, sorted ascending, and +writes each row's 256 weight bytes and 8 scale bytes at the corresponding +output slot. `Pread` is the current implementation. The interface can describe +another source, but does not itself implement remote table storage. Staging +rejects out-of-range IDs before fetching and returns the distinct row count +plus each original ID's local staging index. + +Each stream owns a lane slot. A window executes as that slot's own sub-forward; +retaining only a prefix rolls the slot back and re-feeds those rows. Generation +uses greedy draws with drafts disabled. The native load limit defaults to 70% +of RAM unless `GLM_LOAD_LIMIT_GB` explicitly overrides it. `--engram-stream` +leaves the two large tables on storage and binds the backbone in memory. + +## Template fixtures + +The G8 checker renders a supplied template with the engine's Jinja against the +release's `encoding/tests/test_input_N.json` and `test_output_N.txt` fixtures. +It follows their `test_examples_encoding_golden_outputs` convention, defaulting +`thinking_mode` to `chat` unless a case supplies one. This checks template +bytes, not model inference or tokenizer-normalization parity. diff --git a/docs/deepseek-v41-mhc-fp64.json b/docs/deepseek-v41-mhc-fp64.json new file mode 100644 index 0000000000..833a67ae98 --- /dev/null +++ b/docs/deepseek-v41-mhc-fp64.json @@ -0,0 +1,103 @@ +{ + "scope": "mHC (hyperconnection mix) stage fp64 qualification: rounding level, Metal deltas and 1-ULP input amplification. Independent CPU reference parameterized comptime (math.zig MixOf/denseOf/mixesOf/splitOf/inverseOf/collapseOf/expandOf, f32 source-contract path bit-identical, f64 ideal path); harness zig/tests/deepseek_v41_reference/operator_hc_fp64.zig; fixtures hc_attention_rms.json and hc_fp64_rows.json (retained inputs only).", + "nrms_definition": "sqrt(sum(delta^2)/sum(ref^2)) in f64; ULP = |delta| / fp32 spacing at the fp64 reference value.", + "inputs": [ + "token671_attention (retained token-671 embedding, four copies)", + "token1_ffn (retained token-1 FFN stream and branch, real hc_ffn scale/base)", + "eight_teacher_rows (the eight retained teacher embeddings)", + "wide_dynamic_range (copy magnitudes 2^24..2^-12, rotating signs)", + "split_args (16 retained unary arguments incl. 1.4393715858459473, controlling post argument, integer exp sweep -32..32, plus sigmoid/softmax spans)", + "cancellation fixtures (half-ULP terms after 1.0)" + ], + "rounding_level_fp32_reference_vs_fp64": { + "inverse_statistic_nrms": [ + 2.7e-09, + 2.8e-08 + ], + "projection_dense_nrms": [ + 2.2e-08, + 2.9e-08 + ], + "pre_sigmoid_nrms": [ + 1.1e-08, + 4.2e-08 + ], + "post_nrms": [ + 3.9e-08, + 7.2e-08 + ], + "comb_sinkhorn_nrms": [ + 1.4e-08, + 6.8e-08 + ], + "collapse_bf16_unequal_cells": 0, + "post_expand_bf16_unequal_cells": [ + 0, + 5 + ] + }, + "metal_vs_fp64": { + "inverse_max_ulp": 0.6, + "projection_max_ulp": 0.5, + "pre_max_ulp": 2.75, + "post_max_ulp": 2.29, + "comb_max_ulp_near_cancelled_cells_only": 34.2, + "comb_max_abs": 1.5e-07, + "post_controlling_exponent_row_max_ulp": 1.61, + "exponent_sweep_minus32_to_32_worst_stage_nrms": 9e-08, + "bf16_unequal_cells": { + "token671": 0, + "token1_ffn": "3 of 20480", + "eight_teacher_rows": "8 of 163840", + "wide_dynamic_range": 0 + }, + "verdict": "every sub-stage sits at the fp32 source-contract rounding level; Metal exceeds the fp32 reference by at most ~1 ULP at the documented Metal exp boundary" + }, + "cancellation_boundaries": [ + { + "fixture": "collapse_half_ulps", + "metal": "0x3500", + "f32_ref": "0x3500", + "f64_ideal": "0x34c0" + }, + { + "fixture": "post_half_ulps", + "metal": "0x0", + "f32_ref": "0x0", + "f64_ideal": "0x3400" + }, + { + "fixture": "branch_product", + "metal": "0x3500", + "f32_ref": "0x3500", + "f64_ideal": "0x34c0" + } + ], + "amplification_1ulp_input": { + "method": "perturb one bf16 stream cell (or branch cell) by 1 ULP, propagate the ideal fp64 stage, output NRMS growth = sqrt(sum(dy^2)/sum(y^2)) / (|dx|/sqrt(sum(x^2))); 256 sampled cells per row; 8 teacher positions plus the token-1 FFN row", + "analog_fp64_gain_mean_per_row": [ + 0.182, + 0.76 + ], + "analog_fp64_gain_max_overall": 0.762, + "collapse_gain_mean": 0.5003, + "collapse_gain_max": 1.944, + "bf16_visible_gain_mean_per_row": [ + 0.74, + 2.382 + ], + "bf16_visible_gain_max": 34.5, + "bf16_rows_changed_fraction": "224-250 of 256 perturbations change at least one of 20480 output cells", + "mean_changed_cells": "8.1-13.1 of 20480", + "statistic_relative_shift_mean": 3.6e-07, + "comb_nrms_shift_mean": 3.6e-08, + "metal_confirmation_row0": { + "amp_mean": 1.886, + "fp64_bf16_amp_mean": 1.945, + "mean_changed_cells": 12.06 + } + }, + "gate_reading": "A single-ULP input difference is attenuated by the mix in exact arithmetic (gain < 1) and appears as 1-bf16-ULP flips on about 0.06 percent of output cells; the stage's own rounding-level output NRMS is 0 to 2.5e-5. Hyperconnection mixing therefore cannot turn fp32 ULP noise into the observed 4.096 percent whole-model NRMS; a specific operator-level difference elsewhere (sparse/compressed attention candidates) remains the plausible cause.", + "kernel_changes": "none required; no tolerance relaxed, no source coefficient injected", + "receipts": "gitignored receipts/ (mhc-fp64-run.log, mhc-fp64-parsed.json, build-summary.log); commit contains code, fixture and this summary" +} \ No newline at end of file diff --git a/docs/deepseek-v41.md b/docs/deepseek-v41.md new file mode 100644 index 0000000000..d22d6e58fa --- /dev/null +++ b/docs/deepseek-v41.md @@ -0,0 +1,209 @@ +# DeepSeek-V4.1-Flash on one Mac + +The Metal path is experimental. Native serving requires +`TF_EXPERIMENTAL=deepseek` while the shared whole-model release gate remains open. +The earlier family qualification below used different checkpoints. The pinned +1017 receipts establish parity for their measured inputs, not release approval +for the LibertAIDAI pack; see [the shared gate discussion](https://github.com/ashhart/TensorFold/issues/847). + +TensorFold serves the whole DeepSeek-V4.1-Flash from the resident EXL3 packs on one Apple +Silicon Mac. The engine runs on the lane core (one stream per slot, with windows of rows), so +concurrent requests each keep their own caches and Engram cursor, and replies are greedy +(`temperature 0`); drafts, grammar masks and tree requests are refused until their pieces +land. The served checks hold: a drafted request reports `"draft": false` and equals the +no-drafts reply, a resumed conversation equals the fresh one, and concurrent lanes equal +their solo runs. + +## What you need + +- One Mac with 512 GB of unified memory. The 2.9bpw pack stages about 413 GB of weights + and the 2.8bpw pack about 190 GB; the server stays at 70% of RAM unless + `GLM_LOAD_LIMIT_GB` opts past it, as GLM's load does. +- The original release's inference directory (`tokenizer.json`, `config.json`, + `chat_template.jinja` and the two Engram tables), beside the pack or passed with + `--engram-table-dir`. The weights come under DeepSeek's own terms; the inference code + is MIT and the native reference follows it (see THIRD_PARTY_NOTICES). +- An EXL3 pack. The qualification ran jayleaton's 2.9bpw and 2.8bpw repacks; the 2.8bpw + pack's duplicated tensors are owned by the index, so a shard contributes only the + tensors the index maps to it. + +## The token map + +The Engram tables are keyed by a compressed vocabulary derived from the release's own +tokenizer and inference config. The native engine builds the map itself. It decodes each +id, falls back to the raw token when the decoder emits U+FFFD, normalizes through the +release's chain (NFKD, strip marks, lowercase, the whitespace replaces) and deduplicates, +with multipliers drawn from NumPy's PCG64 exactly as the source does. The result is +byte-identical to the map shipped beside the original qualified 2.9bpw pack. +Full normalization parity for other tokenizers, including Hangul, remains +[unqualified](engram-token-map.md#normalization-qualification). When `engram-token-map.bin` +is missing beside the checkpoint and the original's tokenizer sits beside the tables, +the engine builds it at open; no Python step. To build it by hand: + +```sh +zig build tf-dsv41-token-map -Dcpu=apple_m3 && ./zig-out/bin/tf-dsv41-token-map \ + --tokenizer /tokenizer.json --config /config.json --output /engram-token-map.bin +``` + +## Serve + +```sh +TF_EXPERIMENTAL=deepseek GLM_LOAD_LIMIT_GB=430 tensorfold-native serve \ + --engram-table-dir --chat-template /chat_template.jinja \ + --context 65536 --parallel 2 --no-drafts --prompt-cache-gib 0 \ + --host 127.0.0.1 --port 8099 --name DeepSeek-V4.1-Flash --keep-warm 0 +``` + +`--parallel` is the lane count: each lane is one stream with its own caches and Engram +cursor. `--no-drafts` requests greedy decoding. A drafted request answers with +`"draft": false` and the same tokens. The resident load +reads no weights from disk during execution; a run that does fails the receipt's +residency gate. + +## 256 GB: the Engram tables from disk + +The two Engram tables are about 203 GB of the release, and a token touches only 48 of +their rows (24 per table, 256 bytes each). `--engram-stream` leaves them in their +shards: the load checks both table headers as a resident load does but stages neither, +and it stages only the tensors the backbone binds (no `mtp.*`, `vision.*`, `aligner.*` +or image markers). Each chunk then hashes its row IDs in a command buffer of its own, +reads each table's distinct rows with uncached `pread`s on a few threads, and runs the +rest of the forward with the same gather kernel over the staged rows, so a streamed +reply is the resident reply bit for bit (the gather check stages a synthetic shard and +compares the two gathers byte for byte). The scale columns, 8 bytes a row, are read +once at open and held in host memory (about 6.1 GB); they count against the load +limit, not Metal admission. + +```sh +TF_EXPERIMENTAL=deepseek GLM_LOAD_LIMIT_GB=220 tensorfold-native serve \ + --engram-table-dir --engram-stream --chat-template