diff --git a/js/README.md b/js/README.md
index dcd93fbd..5093cb2e 100644
--- a/js/README.md
+++ b/js/README.md
@@ -52,3 +52,5 @@ Click links for README of each examples.
* [Phi-3-mini-4k-instruct](chat) - demonstrates how to run [Phi-3-mini-4k-instruct](https://huggingface.co/microsoft/Phi-3-mini-4k-instruct) in your browser using [onnxruntime-web](https://github.com/microsoft/onnxruntime/js) with webgpu.
+
+* [Qwen3.8-27B INT4](qwen38-webgpu) - demonstrates community-converted Qwen3.8-27B text generation with ONNX Runtime WebGPU, JSPI external-data loading and recurrent-state caching.
diff --git a/js/qwen38-webgpu/.gitignore b/js/qwen38-webgpu/.gitignore
new file mode 100644
index 00000000..3e18c3c3
--- /dev/null
+++ b/js/qwen38-webgpu/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+dist/
+.models/
+results/
+.env
+.DS_Store
diff --git a/js/qwen38-webgpu/LICENSE b/js/qwen38-webgpu/LICENSE
new file mode 100644
index 00000000..4dd2987e
--- /dev/null
+++ b/js/qwen38-webgpu/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2026 Ruurd Kuiper
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
\ No newline at end of file
diff --git a/js/qwen38-webgpu/README.md b/js/qwen38-webgpu/README.md
new file mode 100644
index 00000000..3cf327b8
--- /dev/null
+++ b/js/qwen38-webgpu/README.md
@@ -0,0 +1,47 @@
+# Qwen3.8-27B in the browser with ONNX Runtime WebGPU
+
+A minimal text-generation example using a public **Qwen3.8-27B INT4** model pack, ONNX Runtime Web's **JSPI/Blob loader**, and hardware WebGPU. It runs independently of Clinstruct. All model operators execute locally on GPU, with CPU inference fallback disabled; JavaScript handles tokenization, sequence metadata and greedy sampling.
+
+**Weights: approximately 15.26 GB. Tested on a 48 GiB Apple Silicon Mac, Chrome 154, Metal 3 WebGPU.** Earlier integrated tests measured about 5–6 tokens/s; the standalone 32-token smoke test measured about 10 tokens/s. See [validation.json](validation.json) for exact timings. Other hardware and long contexts are unvalidated. This is a runnable engineering example, not a quality benchmark or an official Microsoft/Alibaba release.
+
+## Run
+
+Install Node.js ≥20.19. Use current Chrome or Edge with hardware WebGPU, `shader-f16` and WebAssembly JSPI, in an ordinary browser profile.
+
+```sh
+git clone https://github.com/RuurdKuiper/qwen38-27b-onnx-webgpu.git
+cd qwen38-27b-onnx-webgpu
+npm ci
+npm run download:model
+npm run dev -- --port 5174
+```
+
+Open `http://127.0.0.1:5174`, click **Load model**, then generate. The download script gets the [public Hugging Face pack](https://huggingface.co/Ruurd/Qwen3.8-27B-ONNX-WebGPU-INT4) at the immutable commit in `model.json` and verifies file sizes/SHA-256 checksums. Re-running it preserves verified completed shards. The local server only serves static assets; it runs no inference. Prompts remain in the browser.
+
+If the model is already installed elsewhere:
+
+```sh
+MODEL_DIR=/absolute/path/to/model-pack npm run dev -- --port 5174
+```
+
+`npm run build` bundles the application and pinned runtime, excluding weights. To deploy that build, serve the model pack separately at `/models/onnx/Qwen3.8-27B-GenAI-WebGPU-INT4/` on the same secure origin. Never bundle the weights into the application ZIP.
+
+## Model and generation contract
+
+This export requires the complete graph/state contract, not only `input_ids → logits`. The worker inspects `manifest.json` and `genai_config.json` and retains **128 state tensors on GPU**: 32 KV, 48 convolution and 48 recurrent. Each prompt starts fresh state; decoding retains state between tokens. Generation is batch-one contiguous unpadded text, 32 new tokens maximum in this UI, temperature zero, and thinking disabled. Qwen's three-axis position IDs are supplied explicitly.
+
+The native graph is preserved. `model.browser.onnx` replaces six known attention-mask metadata operations with host `seqlens_k` and `total_sequence_length` inputs to avoid CPU placement; weights and model computation are unchanged relative to the native export. INT4 block size is 32; both MatMul and Gather (untied embeddings) are quantized. Vision, MTP, paged attention, speculative decoding and CUDA-specific GatedDeltaNet are excluded.
+
+The example reports model bytes, download/load time, first-token latency, decoding speed, available JS heap statistics, GPU adapter/features and runtime warnings. Heap statistics exclude GPU allocations. The loader uses `onnxruntime-web@1.31.0-dev.20260914-8d85527a0`; Transformers.js 4.3.0 supplies tokenization only. The tested 48 GiB machine is not a measured minimum requirement. Large Blob loading failed in an incognito test.
+
+## Reproduce and test
+
+`export/` contains the pinned Python exporter environment and the official-builder wrapper. The memory-bounded serializer is an optional adapter around the official DEFAULT quantizer; it handles large intermediate weights and emits ≤1 GiB shards without changing quantization arithmetic. See `export/README.md` for commands and architecture limitations.
+
+With Vite running, `npm run test:browser` launches installed Chrome in a fresh ordinary profile, blocks external requests, checks arithmetic, ≥20 consecutive generated tokens, strict GPU execution and all 128 retained state tensors, and writes `results/browser.json`. For another origin set `DEMO_URL`.
+
+## Attribution
+
+Qwen3.8-27B is copyright 2026 Alibaba Cloud under Apache 2.0. The model repository includes its `LICENSE` and `NOTICE`. This browser example and conversion tooling are copyright 2026 Ruurd Kuiper under Apache 2.0; dependencies retain their own licenses. The worker originates from the tested Clinstruct integration and is reproduced here so other applications can use the same generation path.
+
+Upstream projects: [ONNX Runtime](https://github.com/microsoft/onnxruntime), [ONNX Runtime GenAI](https://github.com/microsoft/onnxruntime-genai), [Transformers.js](https://github.com/huggingface/transformers.js), [original checkpoint](https://huggingface.co/Qwen/Qwen3.8-27B).
diff --git a/js/qwen38-webgpu/download-model.mjs b/js/qwen38-webgpu/download-model.mjs
new file mode 100644
index 00000000..e74a25d7
--- /dev/null
+++ b/js/qwen38-webgpu/download-model.mjs
@@ -0,0 +1,36 @@
+// Download immutable public assets; the browser performs all inference locally.
+import { createHash } from 'node:crypto';
+import { createReadStream, createWriteStream } from 'node:fs';
+import { mkdir, readFile, rename, rm, stat } from 'node:fs/promises';
+import { resolve } from 'node:path';
+import { Readable, Transform } from 'node:stream';
+import { pipeline } from 'node:stream/promises';
+const config = JSON.parse(await readFile(new URL('./model.json', import.meta.url), 'utf8'));
+const root = resolve(process.env.MODEL_DIR ?? `.models/${config.id}`);
+await mkdir(root, { recursive: true });
+async function checksum(file) {
+ const hash = createHash('sha256');
+ for await (const chunk of createReadStream(file)) hash.update(chunk);
+ return hash.digest('hex');
+}
+for (const file of config.files) {
+ if (!/^[A-Za-z0-9_.-]+$/.test(file.name) || ['.', '..'].includes(file.name)) throw new Error('Unexpected model filename.');
+ const destination = resolve(root, file.name);
+ try {
+ if ((await stat(destination)).size === file.bytes && await checksum(destination) === file.sha256) {
+ console.log('Verified existing', file.name); continue;
+ }
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
+ const partial = destination + '.part';
+ console.log('Downloading', file.name, file.bytes, 'bytes');
+ const response = await fetch(`https://huggingface.co/${config.repo}/resolve/${config.revision}/${file.name}?download=true`);
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${file.name}`);
+ const hash = createHash('sha256'); let bytes = 0;
+ const verify = new Transform({ transform(chunk, encoding, callback) { bytes += chunk.length; hash.update(chunk); callback(null, chunk); } });
+ try {
+ await pipeline(Readable.fromWeb(response.body), verify, createWriteStream(partial));
+ if (bytes !== file.bytes || hash.digest('hex') !== file.sha256) throw new Error(`Integrity check failed: ${file.name}`);
+ await rename(partial, destination);
+ } catch (error) { await rm(partial, { force: true }); throw error; }
+}
+console.log('Verified model pack ready:', root);
diff --git a/js/qwen38-webgpu/export/.gitignore b/js/qwen38-webgpu/export/.gitignore
new file mode 100644
index 00000000..a25b0059
--- /dev/null
+++ b/js/qwen38-webgpu/export/.gitignore
@@ -0,0 +1,5 @@
+.venv/
+.work/
+node_modules/
+__pycache__/
+*.log
diff --git a/js/qwen38-webgpu/export/README.md b/js/qwen38-webgpu/export/README.md
new file mode 100644
index 00000000..5394d032
--- /dev/null
+++ b/js/qwen38-webgpu/export/README.md
@@ -0,0 +1,27 @@
+# Reproduce the conversion
+
+Run from this repository's root. Requires `uv` and space for the ~55.56 GB original checkpoint, ~15.26 GB output and temporary files. The recorded export used a 48 GiB Mac. These dependencies pin the official builder/runtime tested on 6 October 2026.
+
+```sh
+bash export/setup.sh
+export/.venv/bin/python export/export.py \
+ --model Qwen/Qwen3.8-27B \
+ --revision 1d4bf0f2ff6012fd82039f2fa52739d0dd7c60c0 \
+ --bounded \
+ --output .models/Qwen3.8-27B-GenAI-WebGPU-INT4
+```
+
+The command downloads the pinned checkpoint, exports INT4 with WebGPU, runs offline native GenAI sanity checks, adapts the known attention-mask metadata subgraph and emits the browser manifest. The native CPU check does not serve inference to the browser. `--skip-native` skips it.
+
+`--bounded` retains the official builder and DEFAULT weight-only quantizer, but serializes/quantizes one weight at a time to avoid retaining the whole FP16 intermediate model during serialization. The original baseline was killed while saving. The adapter copies independent protobuf descriptors, handles >2 GiB intermediate weights through temporary external files, quantizes both MatMul/Gather, and emits ≤1 GiB tensor-aligned shards. It matched all 491 initializer shapes/dtypes/hashes and graph nodes in a 2B cross-check against the ordinary exporter. This internal adapter is tied to the pinned builder version.
+
+Defaults are INT4 block size 32, decoder only, `exclude_mtp=true`, `state_window=0`, ordinary non-paged linear attention and MatMul/Gather quantization. No CUDA kernels, vision, MTP, speculative decoding or paged attention. Output must be empty; use a new directory for a retry. Reports are beside the output in `model-name.reports/`.
+
+The original/native graph is retained. The browser adapter removes exactly six known mask metadata nodes and rejects unfamiliar subgraphs. Different architectures need their own input/output/state and position-ID inspection and WebGPU operator validation; the current worker implements the Qwen graph contract. Optional offline graph/state equivalence check:
+
+```sh
+export/.venv/bin/python export/scripts/check_adaptation.py \
+ .models/Qwen3.8-27B-GenAI-WebGPU-INT4
+```
+
+Serve the example and run `npm run test:browser` to test the result. The model's configured context length is not a measured browser capacity. Reproducible numerical serialization does not establish BF16 quality parity or compatibility with arbitrary GPUs.
diff --git a/js/qwen38-webgpu/export/export.py b/js/qwen38-webgpu/export/export.py
new file mode 100644
index 00000000..ea2c0c8d
--- /dev/null
+++ b/js/qwen38-webgpu/export/export.py
@@ -0,0 +1,60 @@
+"""Reproduce the tested GenAI INT4/WebGPU export into a self-contained pack."""
+import argparse
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+
+# Set before importing huggingface_hub: ordinary HTTP avoids the observed Xet stall.
+os.environ.setdefault('HF_HUB_DISABLE_XET', '1')
+os.environ.setdefault('HF_HUB_DOWNLOAD_TIMEOUT', '120')
+os.environ['PYTHONUNBUFFERED'] = '1'
+from huggingface_hub import model_info, snapshot_download
+
+TOOL = Path(__file__).resolve().parent
+parser = argparse.ArgumentParser(description=__doc__)
+parser.add_argument('--model', required=True, help='Official Hugging Face checkpoint ID')
+parser.add_argument('--revision', help='Resolve and record HEAD if omitted')
+parser.add_argument('--output', type=Path, required=True, help='A new/empty model-pack directory')
+parser.add_argument('--cache', type=Path, default=TOOL / '.work/cache')
+parser.add_argument('--bounded', action='store_true', help='Use the validated per-weight serializer for large models')
+parser.add_argument('--skip-native', action='store_true', help='Skip offline CPU sanity validation')
+parser.add_argument('--extra-option', action='append', default=[], help='Additional builder key=value, e.g. block_size=32')
+args = parser.parse_args()
+root = args.output.resolve()
+if root.exists() and any(root.iterdir()):
+ parser.error('Output must be empty. Use a new directory or remove the failed output before retrying.')
+root.mkdir(parents=True, exist_ok=True)
+notes = root.parent / (root.name + '.reports')
+notes.mkdir(parents=True, exist_ok=True)
+info = model_info(args.model, revision=args.revision)
+snapshot = snapshot_download(args.model, revision=info.sha, cache_dir=str(args.cache.resolve()),
+ allow_patterns=['*.json', '*.jinja', '*.safetensors', '*.txt'], max_workers=8)
+metadata = {'model': args.model, 'revision': info.sha, 'precision': 'int4', 'provider': 'webgpu',
+ 'boundedSerializer': args.bounded, 'extraOptions': args.extra_option}
+(root / 'export-info.json').write_text(json.dumps(metadata, indent=2) + '\n')
+
+def run(label, command):
+ print(f'\n{label}', flush=True)
+ with (notes / (label + '.log')).open('w') as log:
+ process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=notes)
+ for line in process.stdout:
+ print(line, end='', flush=True)
+ log.write(line)
+ code = process.wait()
+ if code: raise SystemExit(f'{label} failed ({code}); see {notes}')
+
+builder = [sys.executable, str(TOOL / 'scripts/streaming_export.py')] if args.bounded else [sys.executable, '-m', 'onnxruntime_genai.models.builder']
+# MatMul/Gather also quantizes untied input embeddings, essential for the 27B pack.
+run('export', builder + ['-m', args.model, '-i', snapshot, '-o', str(root), '-p', 'int4', '-e', 'webgpu',
+ '-c', str(args.cache.resolve()), '--extra_options', 'exclude_mtp=true', 'state_window=0',
+ 'op_types_to_quantize=MatMul/Gather', *args.extra_option])
+if not args.skip_native:
+ run('native-validation', [sys.executable, str(TOOL / 'scripts/validate.py'), str(root)])
+# Keep small exports' original external-data layout; split oversized baseline files.
+if any(p.stat().st_size >= 2**31 for p in root.glob('*.onnx.data')):
+ run('sharding', [sys.executable, str(TOOL / 'scripts/shard_model.py'), str(root)])
+run('browser-adaptation', [sys.executable, str(TOOL / 'scripts/prepare_browser.py'), str(root)])
+run('inspection', [sys.executable, str(TOOL / 'scripts/inspect_model.py'), str(root)])
+print(f'\nReady for browser validation: {root}\nReports: {notes}')
diff --git a/js/qwen38-webgpu/export/requirements-export.lock b/js/qwen38-webgpu/export/requirements-export.lock
new file mode 100644
index 00000000..43bfc739
--- /dev/null
+++ b/js/qwen38-webgpu/export/requirements-export.lock
@@ -0,0 +1,41 @@
+annotated-doc==0.0.5
+anyio==4.15.1
+certifi==2026.7.22
+click==8.5.0
+filelock==4.0.12
+flatbuffers==25.12.19
+fsspec==2026.9.0
+h11==0.16.0
+hf-xet==1.6.0
+httpcore==1.0.9
+httpx==0.28.1
+huggingface-hub==1.33.0
+idna==3.20
+jinja2==3.1.6
+markdown-it-py==4.2.0
+markupsafe==3.0.4
+mdurl==0.1.2
+ml-dtypes==0.6.0
+mpmath==1.3.0
+networkx==3.7
+numpy==2.5.3
+onnx==1.23.2
+onnx-ir==1.0.0
+onnxruntime==1.30.0
+onnxruntime-genai==0.17.1
+packaging==26.3
+protobuf==7.36.2
+pygments==2.21.0
+pyyaml==6.0.3
+regex==2026.9.29
+rich==15.0.0
+safetensors==0.8.0
+setuptools==84.0.0
+shellingham==1.5.4
+sympy==1.14.0
+tokenizers==0.23.2
+torch==2.14.1
+tqdm==4.70.1
+transformers==5.18.0
+typer==0.27.2
+typing-extensions==4.16.0
diff --git a/js/qwen38-webgpu/export/scripts/catalog_entry.py b/js/qwen38-webgpu/export/scripts/catalog_entry.py
new file mode 100644
index 00000000..ed508a8d
--- /dev/null
+++ b/js/qwen38-webgpu/export/scripts/catalog_entry.py
@@ -0,0 +1,23 @@
+"""Print a catalog entry for a newly exported pack; review it before adding it."""
+import argparse
+import json
+from pathlib import Path
+
+parser = argparse.ArgumentParser(description=__doc__)
+parser.add_argument('model_dir', type=Path)
+parser.add_argument('--id', required=True)
+parser.add_argument('--label', required=True)
+parser.add_argument('--family', required=True)
+parser.add_argument('--parameters', type=float, required=True)
+parser.add_argument('--license', required=True)
+parser.add_argument('--license-url', required=True)
+args = parser.parse_args()
+manifest = json.loads((args.model_dir / 'manifest.json').read_text())
+info = json.loads((args.model_dir / 'export-info.json').read_text())
+assert manifest.get('browserGraph'), 'Prepare/inspect the browser graph first.'
+entry = {'id': args.id, 'label': args.label, 'family': args.family, 'parametersB': args.parameters,
+ 'repo': info['model'], 'revision': info['revision'], 'dtype': 'q4f16', 'runtime': 'genai',
+ 'loader': 'jspi', 'source': 'local-export', 'downloadMb': round(manifest['totalBytes'] / 1e6),
+ 'licenseName': args.license, 'licenseUrl': args.license_url,
+ 'files': [f['name'] for f in manifest['files']] + ['manifest.json']}
+print(json.dumps(entry, indent=2))
diff --git a/js/qwen38-webgpu/export/scripts/check_adaptation.py b/js/qwen38-webgpu/export/scripts/check_adaptation.py
new file mode 100644
index 00000000..17bbdceb
--- /dev/null
+++ b/js/qwen38-webgpu/export/scripts/check_adaptation.py
@@ -0,0 +1,47 @@
+"""Compare original/adapted graph logits and every cache after prefill/decode."""
+import json
+import sys
+from pathlib import Path
+
+import numpy as np
+import onnxruntime as ort
+
+root = Path(sys.argv[1]).resolve()
+notes = root.parent / (root.name + ".reports")
+notes.mkdir(parents=True, exist_ok=True)
+config = json.loads((root / "genai_config.json").read_text())
+manifest = json.loads((root / "manifest.json").read_text())
+graph = next(g for g in manifest["graphs"] if g["file"] == config["model"]["decoder"]["filename"])
+original = ort.InferenceSession(str(root / graph["file"]), providers=["CPUExecutionProvider"])
+adapted = ort.InferenceSession(str(root / "model.browser.onnx"), providers=["CPUExecutionProvider"])
+reference = json.loads((notes / "native-validation-cpu.json").read_text())["tests"][0]
+symbols = {"batch_size": 1, "past_sequence_length": 0, "kv_cache_dim": config["model"]["decoder"]["head_size"]}
+state = {}
+for value in graph["inputs"]:
+ if value["name"].startswith("past"):
+ shape = [symbols.get(d, d) for d in value["dims"]]
+ state[value["name"]] = np.zeros(shape, dtype=np.float16)
+ids = reference["input_ids"]
+past = 0
+checks = []
+for step in range(2):
+ total = past + len(ids)
+ feeds = {**state, "input_ids": np.asarray([ids], dtype=np.int64),
+ "position_ids": np.tile(np.arange(past, total, dtype=np.int64), (3, 1, 1)),
+ "attention_mask": np.ones((1, total), dtype=np.int64)}
+ original_outputs = dict(zip([v.name for v in original.get_outputs()], original.run(None, feeds)))
+ browser_feeds = {k: v for k, v in feeds.items() if k != "attention_mask"}
+ browser_feeds.update(seqlens_k=np.asarray([total - 1], dtype=np.int32),
+ total_sequence_length=np.asarray(total, dtype=np.int32))
+ browser_outputs = dict(zip([v.name for v in adapted.get_outputs()], adapted.run(None, browser_feeds)))
+ equal = {name: np.array_equal(value, browser_outputs[name]) for name, value in original_outputs.items()}
+ assert all(equal.values()), f"Graph adaptation changed outputs: {equal}"
+ checks.append({"step": step, "inputTokens": len(ids), "outputsCompared": len(equal), "allBitwiseEqual": True})
+ for name in state:
+ output = name.replace("past_key_values.", "present.").replace("past.", "present.")
+ state[name] = original_outputs[output]
+ ids = [int(np.argmax(original_outputs["logits"][0, -1]))]
+ past = total
+result = {"status": "passed", "provider": "cpu", "purpose": "offline graph-adaptation equivalence check", "checks": checks}
+(notes / "adaptation-equivalence.json").write_text(json.dumps(result, indent=2) + "\n")
+print(json.dumps(result, indent=2))
diff --git a/js/qwen38-webgpu/export/scripts/inspect_model.py b/js/qwen38-webgpu/export/scripts/inspect_model.py
new file mode 100644
index 00000000..0ef28a13
--- /dev/null
+++ b/js/qwen38-webgpu/export/scripts/inspect_model.py
@@ -0,0 +1,55 @@
+"""Read graph metadata without loading external weights; emit a browser manifest."""
+import json
+import sys
+from collections import Counter
+from pathlib import Path
+
+import onnx
+
+root = Path(sys.argv[1]).resolve()
+notes = root.parent / (root.name + ".reports")
+notes.mkdir(parents=True, exist_ok=True)
+
+def tensor_info(value):
+ tensor = value.type.tensor_type
+ return {
+ "name": value.name,
+ "type": onnx.TensorProto.DataType.Name(tensor.elem_type).lower(),
+ "dims": [d.dim_param if d.dim_param else d.dim_value for d in tensor.shape.dim],
+ }
+
+graphs = []
+for path in sorted(root.glob("*.onnx")):
+ model = onnx.load(path, load_external_data=False)
+ data_files = set()
+ for tensor in model.graph.initializer:
+ for item in tensor.external_data:
+ if item.key == "location":
+ data_files.add(item.value)
+ graphs.append({
+ "file": path.name,
+ "inputs": [tensor_info(v) for v in model.graph.input],
+ "outputs": [tensor_info(v) for v in model.graph.output],
+ "externalData": sorted(data_files),
+ "operators": dict(sorted(Counter(f"{n.domain or 'ai.onnx'}::{n.op_type}" for n in model.graph.node).items())),
+ "opsets": {v.domain or "ai.onnx": v.version for v in model.opset_import},
+ "int4": [{"name": n.name, "bits": onnx.helper.get_attribute_value(next(a for a in n.attribute if a.name == "bits")),
+ "blockSize": onnx.helper.get_attribute_value(next(a for a in n.attribute if a.name == "block_size"))}
+ for n in model.graph.node if n.op_type == "MatMulNBits"],
+ "largestInitializers": sorted(
+ [{"name": t.name, "dims": list(t.dims), "type": onnx.TensorProto.DataType.Name(t.data_type).lower(),
+ "externalBytes": int(next((v.value for v in t.external_data if v.key == "length"), "0"))}
+ for t in model.graph.initializer], key=lambda t: t["externalBytes"], reverse=True)[:10],
+ })
+files = [{"name": str(p.relative_to(root)), "bytes": p.stat().st_size} for p in sorted(root.rglob("*")) if p.is_file() and p.name != "manifest.json"]
+manifest = {"files": files, "totalBytes": sum(f["bytes"] for f in files), "graphs": graphs}
+if (root / "browser-adaptation.json").exists():
+ manifest["browserAdaptation"] = json.loads((root / "browser-adaptation.json").read_text())
+ manifest["browserGraph"] = manifest["browserAdaptation"]["browserGraph"]
+(root / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
+(notes / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
+for file in files:
+ print(f"{file['bytes']:>14,} bytes {file['name']}")
+print(f"Total: {manifest['totalBytes']:,} bytes")
+for graph in graphs:
+ print(json.dumps(graph, indent=2))
diff --git a/js/qwen38-webgpu/export/scripts/prepare_browser.py b/js/qwen38-webgpu/export/scripts/prepare_browser.py
new file mode 100644
index 00000000..98f63626
--- /dev/null
+++ b/js/qwen38-webgpu/export/scripts/prepare_browser.py
@@ -0,0 +1,79 @@
+"""Expose GQA sequence metadata as host inputs; leave model math/weights intact.
+
+This adapter only supports batch size one, contiguous unpadded text. It removes
+the six attention-mask metadata nodes that ORT explicitly places on CPU. The
+original GenAI export is retained for native validation.
+"""
+import json
+import sys
+from pathlib import Path
+
+import onnx
+from onnx import TensorProto, helper
+
+root = Path(sys.argv[1]).resolve()
+notes = root.parent / (root.name + ".reports")
+notes.mkdir(parents=True, exist_ok=True)
+config = json.loads((root / "genai_config.json").read_text())
+original = config["model"]["decoder"]["filename"]
+model = onnx.load(root / original, load_external_data=False)
+prefix = "/model/attn_mask_reformat/attn_mask_subgraph/"
+nodes = [n for n in model.graph.node if n.name.startswith(prefix)]
+expected = {"ReduceSum", "Sub", "Sub/Cast", "Shape", "Gather", "Gather/Cast"}
+assert {n.name.removeprefix(prefix) for n in nodes} == expected, "Unknown mask subgraph; inspect before adapting"
+replacements = {
+ prefix + "Sub/Cast/output_0": "seqlens_k",
+ prefix + "Gather/Cast/output_0": "total_sequence_length",
+}
+removed_outputs = {o for n in nodes for o in n.output}
+remaining = [n for n in model.graph.node if n not in nodes]
+gqa = [n for n in remaining if n.op_type == "GroupQueryAttention"]
+assert gqa and all(n.input[5] in replacements and n.input[6] in replacements for n in gqa)
+for node in remaining:
+ for index, name in enumerate(node.input):
+ if name in replacements:
+ node.input[index] = replacements[name]
+ else:
+ assert name not in removed_outputs, f"Unexpected mask-subgraph consumer: {node.name}"
+del model.graph.node[:]
+model.graph.node.extend(remaining)
+mask = config["model"]["decoder"]["inputs"]["attention_mask"]
+inputs = [v for v in model.graph.input if v.name != mask]
+inputs.extend([
+ helper.make_tensor_value_info("seqlens_k", TensorProto.INT32, ["batch_size"]),
+ helper.make_tensor_value_info("total_sequence_length", TensorProto.INT32, []),
+])
+del model.graph.input[:]
+model.graph.input.extend(inputs)
+values = [v for v in model.graph.value_info if v.name not in removed_outputs]
+del model.graph.value_info[:]
+model.graph.value_info.extend(values)
+browser = "model.browser.onnx"
+onnx.save_model(model, root / browser)
+# ORT's exporter uses an ai.onnx SimplifiedLayerNormalization extension that the
+# generic ONNX checker may not recognize. Require the same failure on the untouched
+# export; semantic validation is performed by check_adaptation.py through ORT.
+checker_warning = None
+try:
+ onnx.checker.check_model(str(root / browser))
+except onnx.checker.ValidationError as error:
+ try:
+ onnx.checker.check_model(str(root / original))
+ except onnx.checker.ValidationError as baseline:
+ if str(error).split("\n")[0] != str(baseline).split("\n")[0]:
+ raise
+ checker_warning = str(error).split("\n")[0]
+ else:
+ raise
+adaptation = {
+ "originalGraph": original, "browserGraph": browser,
+ "removedNodes": [{"name": n.name, "op": n.op_type} for n in nodes],
+ "newInputs": {"seqlens_k": "int32[1] = total tokens processed including this step - 1",
+ "total_sequence_length": "int32 scalar = total tokens processed including this step"},
+ "restriction": "batch size 1, contiguous unpadded text only",
+ "weightsModified": False,
+ "onnxCheckerWarningAlsoPresentInOriginal": checker_warning,
+}
+(root / "browser-adaptation.json").write_text(json.dumps(adaptation, indent=2) + "\n")
+(notes / "browser-adaptation.json").write_text(json.dumps(adaptation, indent=2) + "\n")
+print(json.dumps(adaptation, indent=2))
diff --git a/js/qwen38-webgpu/export/scripts/shard_model.py b/js/qwen38-webgpu/export/scripts/shard_model.py
new file mode 100644
index 00000000..ebb2d6e8
--- /dev/null
+++ b/js/qwen38-webgpu/export/scripts/shard_model.py
@@ -0,0 +1,77 @@
+"""Repack an official single-file export into <=1 GiB tensor-aligned shards.
+
+Numerical tensors and graph nodes are unchanged. Verify every copied byte before
+publishing the new graph and removing the now-unreferenced original data file.
+"""
+import hashlib
+import json
+import sys
+import os
+from pathlib import Path
+import onnx
+from onnx import external_data_helper
+
+root = Path(sys.argv[1]).resolve()
+notes = root.parent / (root.name + ".reports")
+notes.mkdir(parents=True, exist_ok=True)
+model_path = root / 'model.onnx'
+model = onnx.load(model_path, load_external_data=False)
+locations = {entry.value for tensor in model.graph.initializer for entry in tensor.external_data if entry.key == 'location'}
+assert locations == {'model.onnx.data'}, 'Expected an unsharded export; do not run this on an already sharded model.'
+limit = 1024**3
+original_files = set()
+shards = []
+writer = None
+index = -1
+checks = []
+try:
+ for tensor in model.graph.initializer:
+ ext = {entry.key: entry.value for entry in tensor.external_data}
+ if 'location' not in ext:
+ continue
+ original_files.add(ext['location'])
+ length = int(ext['length'])
+ if length > limit:
+ raise ValueError(f'Tensor exceeds shard limit: {tensor.name}, {length}')
+ padding = (-writer.tell()) % 64 if writer else 0
+ if writer is None or writer.tell() + padding + length > limit:
+ if writer: writer.close()
+ index += 1
+ location = f'model.onnx.data_{index:03d}'
+ shards.append(location)
+ writer = (root / location).open('wb')
+ padding = 0
+ writer.write(b'\0' * padding)
+ offset = writer.tell()
+ digest = hashlib.sha256()
+ with (root / ext['location']).open('rb') as source:
+ source.seek(int(ext.get('offset', 0)))
+ remaining = length
+ while remaining:
+ data = source.read(min(remaining, 8 * 1024 * 1024))
+ if not data: raise EOFError(tensor.name)
+ writer.write(data); digest.update(data); remaining -= len(data)
+ tensor.raw_data = b''
+ external_data_helper.set_external_data(tensor, location=location, offset=offset, length=length)
+ tensor.ClearField('raw_data')
+ tensor.data_location = onnx.TensorProto.EXTERNAL
+ checks.append({'tensor': tensor.name, 'file': location, 'offset': offset,
+ 'bytes': length, 'sha256': digest.hexdigest()})
+finally:
+ if writer: writer.close()
+for check in checks:
+ digest = hashlib.sha256()
+ with (root / check['file']).open('rb') as source:
+ source.seek(check['offset']); remaining = check['bytes']
+ while remaining:
+ data = source.read(min(remaining, 8 * 1024 * 1024)); assert data
+ digest.update(data); remaining -= len(data)
+ assert digest.hexdigest() == check['sha256'], check['tensor']
+onnx.save_model(model, root / 'model.sharded.part.onnx')
+(root / 'model.sharded.part.onnx').replace(model_path)
+for name in original_files:
+ if name not in shards: (root / name).unlink()
+report = {'tensorBytesVerified': sum(check['bytes'] for check in checks), 'tensors': checks,
+ 'shards': [{'file': name, 'bytes': (root / name).stat().st_size} for name in shards]}
+(notes / 'sharding.json').write_text(json.dumps(report, indent=2) + '\n')
+print(json.dumps({'verifiedTensorCount': len(checks), 'shards': report['shards']}, indent=2))
diff --git a/js/qwen38-webgpu/export/scripts/streaming_export.py b/js/qwen38-webgpu/export/scripts/streaming_export.py
new file mode 100644
index 00000000..2c15cfdc
--- /dev/null
+++ b/js/qwen38-webgpu/export/scripts/streaming_export.py
@@ -0,0 +1,167 @@
+"""Memory-bounded save adapter around the official builder and ORT quantizer.
+
+Only use if the baseline exporter exceeds this host's memory ceiling. This
+serializes/quantizes one weight at a time with the official DEFAULT quantizer,
+writing quantized bytes directly to their final external-data file. No model
+operators, quantization settings or inference kernels are replaced.
+"""
+import gc
+import importlib.util
+import json
+import os
+import runpy
+import sys
+import math
+from pathlib import Path
+
+import onnx
+import numpy as np
+import onnx_ir as ir
+from onnx import helper, numpy_helper, external_data_helper
+from onnxruntime.quantization.matmul_nbits_quantizer import DefaultWeightOnlyQuantizer, DefaultWeightOnlyQuantConfig, QuantFormat
+
+source = Path(importlib.util.find_spec('onnxruntime_genai.models.builder').origin).parent
+sys.path.insert(0, str(source))
+from builders.base import Model
+
+
+def memory_bounded_save(self, out_dir):
+ assert self.onnx_dtype == ir.DataType.INT4 and self.ep == 'webgpu'
+ assert getattr(self, 'quantization_algo', 'default') == 'default'
+ assert not getattr(self, 'int4_customized_weight_config', {})
+ assert not getattr(self, 'exact_quant_overrides', {})
+ assert not self.quant_attrs['use_qdq']
+ self.model.graph.sort()
+ Model.stamp_build_metadata(self.model)
+ originals = {name: value.const_value for name, value in self.model.graph.initializers.items()}
+ # Serialize graph metadata without materializing the full FP16 model.
+ for name, value in self.model.graph.initializers.items():
+ tensor = originals[name]
+ value.const_value = ir.ExternalTensor('pending', 0, 0, tensor.dtype,
+ shape=tensor.shape, name=name)
+ proto = ir.to_proto(self.model)
+ for name, value in self.model.graph.initializers.items():
+ value.const_value = originals[name]
+ del proto.graph.initializer[:]
+ nodes = list(proto.graph.node)
+ del proto.graph.node[:]
+ out = Path(out_dir)
+ out.mkdir(parents=True, exist_ok=True)
+ shard_limit = int(os.environ.get('SHARD_BYTES', 1024 ** 3))
+ shard_index = 0
+ location = self.filename + f'.data_{shard_index:03d}'
+ shard_paths = [out / location]
+ stored = {}
+ records = []
+
+ with (out / location).open('wb') as data_file:
+ def store(tensor):
+ nonlocal data_file, location, shard_index
+ if tensor.name in stored:
+ return
+ raw = tensor.raw_data or numpy_helper.to_array(tensor).tobytes()
+ if len(raw) > shard_limit:
+ raise ValueError(f'Tensor exceeds shard limit: {tensor.name}, {len(raw)} bytes')
+ padding = (-data_file.tell()) % 64
+ if data_file.tell() + padding + len(raw) > shard_limit:
+ data_file.close()
+ shard_index += 1
+ location = self.filename + f'.data_{shard_index:03d}'
+ shard_paths.append(out / location)
+ data_file = (out / location).open('wb')
+ padding = 0
+ data_file.write(b'\0' * padding)
+ offset = data_file.tell()
+ data_file.write(raw)
+ length = len(raw)
+ if not tensor.HasField('raw_data'):
+ tensor.raw_data = raw
+ external_data_helper.set_external_data(tensor, location=location, offset=offset, length=length)
+ tensor.ClearField('raw_data')
+ for field in ['float_data', 'int32_data', 'int64_data', 'double_data', 'uint64_data', 'string_data']:
+ tensor.ClearField(field)
+ tensor.data_location = onnx.TensorProto.EXTERNAL
+ # A child protobuf retains its parent graph's allocation arena.
+ # Keeping the mini-graph initializer would therefore retain the
+ # original FP16 payload even after ClearField('raw_data'). Copy only
+ # the small external-data descriptor into an independent message.
+ descriptor = onnx.TensorProto()
+ descriptor.CopyFrom(tensor)
+ stored[tensor.name] = descriptor
+
+ excluded = set(self.quant_attrs['nodes_to_exclude'])
+ ops = set(self.quant_attrs['op_types_to_quantize'])
+ for index, node in enumerate(nodes):
+ if node.op_type not in ops or node.name in excluded:
+ proto.graph.node.append(node)
+ continue
+ weight_name = node.input[0 if node.op_type == 'Gather' else 1]
+ if weight_name not in originals:
+ proto.graph.node.append(node)
+ continue
+ tensor = originals[weight_name]
+ temporary_weight = None
+ elements = math.prod(int(dim) for dim in tensor.shape)
+ if elements * tensor.dtype.bitwidth // 8 >= 2**31:
+ # Protobuf cannot copy a >2 GiB message into a GraphProto.
+ # The official quantizer can consume external-data descriptors.
+ temporary_weight = out / '.quantize-weight.tmp'
+ array = np.ascontiguousarray(tensor.numpy())
+ array.tofile(temporary_weight)
+ del array
+ weight = ir.to_proto(ir.ExternalTensor(
+ str(temporary_weight), 0, temporary_weight.stat().st_size,
+ tensor.dtype, shape=tensor.shape, name=weight_name))
+ else:
+ weight = ir.to_proto(tensor)
+ mini = helper.make_graph([node], 'one_weight', [], [], [weight])
+ del weight
+ quantizer = DefaultWeightOnlyQuantizer(DefaultWeightOnlyQuantConfig(
+ bits=self.quant_attrs['bits'], block_size=self.quant_attrs['matmul_block_size'],
+ is_symmetric=self.quant_attrs['is_symmetric'],
+ accuracy_level=self.quant_attrs['accuracy_level'],
+ quant_format=QuantFormat.QOperator, op_types_to_quantize=tuple(ops)))
+ result_nodes = quantizer.quantize(node, [mini])
+ if temporary_weight:
+ temporary_weight.unlink()
+ assert all(n.op_type != node.op_type for n in result_nodes), node.name
+ proto.graph.node.extend(result_nodes)
+ result = mini
+ used = {name for n in result_nodes for name in n.input}
+ for value in result.initializer:
+ if value.name in used:
+ store(value)
+ records.append({'name': node.name, 'weight': weight_name, 'shardBytes': data_file.tell()})
+ print(f'Stream-quantized {node.name} ({index + 1}/{len(nodes)}), {data_file.tell():,} bytes', flush=True)
+ # LazyTensor caches its cast result; release the temporary FP16 copy.
+ if isinstance(tensor, ir.LazyTensor):
+ tensor._tensor = None
+ del mini, quantizer, result, result_nodes, tensor
+ gc.collect()
+
+ # Emit only initializers still referenced by the transformed graph.
+ used = {name for node in proto.graph.node for name in node.input}
+ for name in sorted(used):
+ if name in originals and name not in stored:
+ tensor = originals[name]
+ store(ir.to_proto(tensor))
+ if isinstance(tensor, ir.LazyTensor):
+ tensor._tensor = None
+ del tensor
+ gc.collect()
+ proto.graph.initializer.extend(stored.values())
+ data_file.close()
+ onnx.save_model(proto, out / self.filename)
+ notes = out.parent / (out.name + ".reports")
+ notes.mkdir(parents=True, exist_ok=True)
+ (notes / "streaming-quantization.json").write_text(json.dumps({
+ 'adapter': 'per-weight serialization with official DEFAULT MatMulNBitsQuantizer',
+ 'settings': self.quant_attrs, 'quantizedNodes': records,
+ 'externalBytes': sum(path.stat().st_size for path in shard_paths),
+ 'shards': [{'file': path.name, 'bytes': path.stat().st_size} for path in shard_paths],
+ }, indent=2, default=str) + '\n')
+
+
+Model.save_model = memory_bounded_save
+if __name__ == '__main__':
+ runpy.run_path(str(source / 'builder.py'), run_name='__main__')
diff --git a/js/qwen38-webgpu/export/scripts/validate.py b/js/qwen38-webgpu/export/scripts/validate.py
new file mode 100644
index 00000000..555e5c6b
--- /dev/null
+++ b/js/qwen38-webgpu/export/scripts/validate.py
@@ -0,0 +1,67 @@
+"""Independent native GenAI sanity check. CPU here is validation, never serving."""
+import json
+import os
+import argparse
+import time
+from pathlib import Path
+
+import numpy as np
+import onnxruntime_genai as og
+from transformers import AutoTokenizer
+
+parser = argparse.ArgumentParser()
+parser.add_argument("model_dir", type=Path)
+parser.add_argument("--provider", choices=["cpu", "webgpu"], default="cpu")
+args = parser.parse_args()
+root = args.model_dir.resolve()
+notes = root.parent / (root.name + ".reports")
+notes.mkdir(parents=True, exist_ok=True)
+os.chdir(notes)
+result = {"provider": args.provider, "purpose": "pre-browser export validation", "tests": []}
+try:
+ config = og.Config(str(root))
+ config.clear_providers()
+ if args.provider != "cpu":
+ config.append_provider(args.provider)
+ model = og.Model(config)
+ tokenizer = AutoTokenizer.from_pretrained(root, local_files_only=True)
+ for prompt in ["What is the capital of France?", "What is 12 * 13? Answer with the number only."]:
+ input_ids = tokenizer.apply_chat_template(
+ [{"role": "user", "content": prompt}], tokenize=True,
+ add_generation_prompt=True, enable_thinking=False, return_dict=False,
+ )
+ params = og.GeneratorParams(model)
+ params.set_search_options(do_sample=False, max_length=len(input_ids) + 32)
+ start = time.perf_counter()
+ generator = og.Generator(model, params)
+ generator.append_tokens(np.asarray(input_ids, dtype=np.int32))
+ generated = []
+ first = None
+ while not generator.is_done() and len(generated) < 32:
+ generator.generate_next_token()
+ generated.append(int(generator.get_next_tokens()[0]))
+ if first is None:
+ first = time.perf_counter()
+ end = time.perf_counter()
+ item = {"prompt": prompt, "input_ids": input_ids, "generated_ids": generated,
+ "text": tokenizer.decode(generated, skip_special_tokens=True),
+ "first_token_ms": (first - start) * 1000 if first else None,
+ "elapsed_ms": (end - start) * 1000}
+ result["tests"].append(item)
+ print(json.dumps(item, indent=2), flush=True)
+ del generator
+ result["checks"] = {
+ "capital": "paris" in result["tests"][0]["text"].lower(),
+ "arithmetic": "156" in result["tests"][1]["text"],
+ }
+ if not all(result["checks"].values()):
+ raise RuntimeError("Native generation completed but failed the answer sanity checks")
+ result["status"] = "passed"
+except Exception as error:
+ result["status"] = "failed"
+ result["error"] = f"{type(error).__name__}: {error}"
+ print(result["error"], flush=True)
+finally:
+ (notes / f"native-validation-{args.provider}.json").write_text(json.dumps(result, indent=2) + "\n")
+if result["status"] != "passed":
+ raise SystemExit(1)
diff --git a/js/qwen38-webgpu/export/setup.sh b/js/qwen38-webgpu/export/setup.sh
new file mode 100644
index 00000000..ce1f1bdc
--- /dev/null
+++ b/js/qwen38-webgpu/export/setup.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")"
+uv venv --python 3.12 .venv
+uv pip install --python .venv/bin/python -r requirements-export.lock
diff --git a/js/qwen38-webgpu/index.html b/js/qwen38-webgpu/index.html
new file mode 100644
index 00000000..db1b88dc
--- /dev/null
+++ b/js/qwen38-webgpu/index.html
@@ -0,0 +1,12 @@
+
+
Qwen3.8 27B · ONNX WebGPU
+
+
Qwen3.8 27B · ONNX Runtime WebGPU
+
Text generation on your GPU. Model download: approximately 15.26 GB. Tested on a 48 GiB Mac with Chrome; other hardware and long contexts are unvalidated.
+
+
+
+
Download the model pack first; see README.md. Use current Chrome or Edge on localhost with hardware WebGPU and JSPI support.