Text Generation
Transformers
Safetensors
qwen3
generative-retrieval
semantic-id
navigational-search
web-search
conversational
text-generation-inference
Instructions to use Octen/Octen-WebGR-4B with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use Octen/Octen-WebGR-4B with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-generation", model="Octen/Octen-WebGR-4B") messages = [ {"role": "user", "content": "Who are you?"}, ] pipe(messages)# pip install -U transformers accelerate # Load model directly from transformers import AutoTokenizer, AutoModelForCausalLM tokenizer = AutoTokenizer.from_pretrained("Octen/Octen-WebGR-4B") model = AutoModelForCausalLM.from_pretrained("Octen/Octen-WebGR-4B", device_map="auto") messages = [ {"role": "user", "content": "Who are you?"}, ] inputs = tokenizer.apply_chat_template( messages, add_generation_prompt=True, tokenize=True, return_dict=True, return_tensors="pt", ).to(model.device) outputs = model.generate(**inputs, max_new_tokens=256) print(tokenizer.decode(outputs[0][inputs["input_ids"].shape[-1]:])) - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- vLLM
How to use Octen/Octen-WebGR-4B with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "Octen/Octen-WebGR-4B" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "Octen/Octen-WebGR-4B", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }'Use Docker
docker model run hf.co/Octen/Octen-WebGR-4B
- SGLang
How to use Octen/Octen-WebGR-4B with SGLang:
Install from pip and serve model
# Install SGLang from pip: pip install sglang # Start the SGLang server: python3 -m sglang.launch_server \ --model-path "Octen/Octen-WebGR-4B" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "Octen/Octen-WebGR-4B", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }'Use Docker images
docker run --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=<secret>" \ --ipc=host \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path "Octen/Octen-WebGR-4B" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "Octen/Octen-WebGR-4B", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }' - Docker Model Runner
How to use Octen/Octen-WebGR-4B with Docker Model Runner:
docker model run hf.co/Octen/Octen-WebGR-4B
File size: 9,398 Bytes
d36c5ee | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 | # Copyright 2026 Octen. Licensed under the Apache License, Version 2.0 (see LICENSE-CODE).
"""CPU inference for Octen-WebGR. Same protocol as infer_gpu.py (query normalisation, prompt, constrained beam search,
scoring); it only removes two algorithmic redundancies of the reference implementation:
1. Level 0 runs the prompt once and keeps its KV cache; the K beams of level 1 reuse it and each feed one token
(the reference recomputes "prompt + SID token" K times, roughly 7-8x the compute).
2. Level-1 log_softmax is taken over the valid children only: sid_table/ is sorted by key = sid0 * 65536 + sid1,
so the valid children of prefix c are the contiguous rows [start[c], start[c+1]).
Results match infer_gpu.py except for occasional swaps between candidates whose scores are nearly tied.
python infer_cpu.py --model Octen/Octen-WebGR-0.6B --query "youtube" "boeing internship" --threads 16
python infer_cpu.py --model /path/to/local/copy --check --threads 16
"""
import argparse, glob, json, os, time
import numpy as np
import pyarrow as pa
import pyarrow.parquet as pq
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
BASE_V, V, MAX_Q = 151936, 65536, 32
PAD = 151643 # <|endoftext|>, used only for left padding (masked out)
def resolve(model):
"""Local directory, or a Hugging Face repo id that is downloaded once into the HF cache."""
if os.path.isdir(model):
return model
from huggingface_hub import snapshot_download
return snapshot_download(model)
def norm_query(q):
return " ".join(q.split()).lower()
class URLs:
"""URL column kept as one array per table file (no combined copy, so peak memory is one table); rows are global."""
def __init__(self, files):
self.parts = [pq.read_table(f, columns=["url"]).column("url").combine_chunks() for f in files]
self.start = np.cumsum([0] + [len(p) for p in self.parts])
def take(self, rows):
f = np.searchsorted(self.start, rows, side="right") - 1
return [self.parts[i][int(r - self.start[i])].as_py() for i, r in zip(f, rows)]
class GRCPU:
def __init__(self, model, dtype=torch.bfloat16, logits_fp32=False):
"""dtype: precision of the forward pass (bf16 matches the GPU evaluation; fp32 is slower).
logits_fp32: compute the lm_head step with fp32 weights (bf16 logits are spaced 0.125 apart at |logit| >= 16,
so nearly tied candidates can swap depending on the kernel)."""
self.root = resolve(model)
self.tok = AutoTokenizer.from_pretrained(self.root)
self.lm = AutoModelForCausalLM.from_pretrained(self.root, dtype=dtype, attn_implementation="sdpa").eval()
assert self.lm.config.vocab_size == BASE_V + 2 * V, "unexpected vocabulary size: should be 151936 + 2 x 65536"
W = self.lm.lm_head.weight.detach()
ldt = torch.float32 if logits_fp32 else dtype
self.W0 = W[BASE_V:BASE_V + V].to(ldt).contiguous() # the 65,536 rows of level 0
self.W1 = W[BASE_V + V:BASE_V + 2 * V].to(ldt).contiguous() # the 65,536 rows of level 1
files = sorted(glob.glob(f"{self.root}/sid_table/part-*.parquet"))
t = pa.concat_tables([pq.read_table(f, columns=["sid0", "sid1"]) for f in files])
s0 = t.column("sid0").to_numpy().astype(np.int64)
s1 = t.column("sid1").to_numpy().astype(np.int64)
assert (np.diff(s0 * V + s1) > 0).all(), "sid_table must be sorted by key in strictly ascending order (one URL per SID)"
self.start = torch.from_numpy(np.searchsorted(s0, np.arange(V + 1))) # valid children of prefix c = rows [start[c], start[c+1])
self.ok0 = self.start[1:] > self.start[:-1] # valid level-0 codes
self.sid1 = torch.from_numpy(s1)
self.url = URLs(files)
self.n = len(s0)
def prompts(self, qs):
qs = [norm_query(q) for q in qs]
qi = self.tok(qs, add_special_tokens=False)["input_ids"]
qs = [self.tok.decode(x[:MAX_Q]) if len(x) > MAX_Q else q for q, x in zip(qs, qi)]
return self.tok([f"Query: {q}\nURL:" for q in qs], add_special_tokens=False)["input_ids"]
@torch.inference_mode()
def search_rows(self, qs, K=20):
"""-> (scores, rows) per query, sorted by score; rows index sid_table/."""
P = self.prompts(qs)
B, T = len(P), max(len(p) for p in P)
ids = torch.full((B, T), PAD, dtype=torch.long)
am = torch.zeros((B, T), dtype=torch.long)
for i, p in enumerate(P): # left padding: the last position is each sequence's last token
ids[i, T - len(p):] = torch.tensor(p)
am[i, T - len(p):] = 1
out = self.lm.model(input_ids=ids, attention_mask=am, position_ids=(am.cumsum(1) - 1).clamp(min=0), use_cache=True)
lg0 = (out.last_hidden_state[:, -1].to(self.W0.dtype) @ self.W0.t()).float().masked_fill(~self.ok0, float("-inf"))
k0 = min(K, int(self.ok0.sum()))
s0, c0 = lg0.log_softmax(-1).topk(k0, dim=1) # [B, k0]
# Level 1: copy the KV cache per beam (the k0 beams of query b are adjacent); each beam feeds only its SID token
cache = out.past_key_values
cache.batch_repeat_interleave(k0)
am1 = torch.cat([am.repeat_interleave(k0, 0), torch.ones((B * k0, 1), dtype=torch.long)], 1)
pos1 = am.sum(1, keepdim=True).repeat_interleave(k0, 0) # position of the new token = number of real tokens
h1 = self.lm.model(input_ids=(BASE_V + c0).reshape(-1, 1), attention_mask=am1, position_ids=pos1,
past_key_values=cache, use_cache=True).last_hidden_state[:, -1]
lg1 = h1.to(self.W1.dtype) @ self.W1.t() # [B*k0, V]; only the valid children's columns are used
cc = c0.reshape(-1)
lo = self.start[cc]
n = self.start[cc + 1] - lo # number of valid children per beam
r = torch.repeat_interleave(torch.arange(B * k0), n) # beam of each child
j = torch.arange(int(n.sum())) - torch.repeat_interleave(n.cumsum(0) - n, n) + lo[r] # table row of each child
x = lg1[r, self.sid1[j]].float()
mx = torch.full((B * k0,), float("-inf")).scatter_reduce(0, r, x, "amax")
lse = mx + torch.zeros(B * k0).index_add_(0, r, (x - mx[r]).exp()).log()
sc = s0.reshape(-1)[r] + x - lse[r] # sum of the two log-probabilities
off = [0] + n.view(B, k0).sum(1).cumsum(0).tolist() # children of one query are adjacent in sc
res = []
for b in range(B):
s, i = sc[off[b]:off[b + 1]].topk(min(K, off[b + 1] - off[b]))
res.append((s.numpy(), j[off[b] + i].numpy()))
return res
def search(self, qs, K=20):
"""-> one list per query of (url, log_prob, table_row), sorted by score (same format as infer_gpu.GR.search)."""
return [list(zip(self.url.take(rr), s.tolist(), rr.tolist())) for s, rr in self.search_rows(qs, K)]
def check(gr, bs=32):
"""Compare against smoke_test.json (hand-picked queries; expected results from the released weights and table)."""
ref = json.load(open(f"{gr.root}/smoke_test.json"))
assert gr.n == ref["table_rows"], f"sid_table has {gr.n:,} rows, expected {ref['table_rows']:,}"
qs, want = [x["query"] for x in ref["cases"]], [x["top20"] for x in ref["cases"]]
ov, top1, n = [], 0, len(qs)
for i in range(0, n, bs):
for res, w in zip(gr.search(qs[i:i + bs], 20), want[i:i + bs]):
got = [u for u, *_ in res]
ov.append(len(set(got) & set(w)) / max(len(w), 1))
top1 += bool(got and w and got[0] == w[0])
mo, t1 = float(np.mean(ov)), top1 / n
print(f"{n} queries: mean top-20 overlap {mo:.4f}, top-1 agreement {t1:.4f}, identical top-20 {np.mean(np.array(ov) == 1):.3f}")
ok = mo >= 0.9 and t1 >= 0.9
print("PASS" if ok else "FAIL (small differences from kernels are expected, but overlap should be >= 0.9)")
return ok
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--model", default=os.path.dirname(os.path.abspath(__file__)), help="local directory or HF repo id")
ap.add_argument("--query", nargs="*", default=[])
ap.add_argument("--k", type=int, default=20)
ap.add_argument("--threads", type=int, default=0, help="0 = torch default")
ap.add_argument("--fp32", action="store_true", help="fp32 forward pass (default bf16, as in the GPU evaluation)")
ap.add_argument("--check", action="store_true")
a = ap.parse_args()
if a.threads:
torch.set_num_threads(a.threads)
t0 = time.time()
gr = GRCPU(a.model, dtype=torch.float32 if a.fp32 else torch.bfloat16)
print(f"Loaded {gr.n:,} candidate URLs, {torch.get_num_threads()} threads, {time.time() - t0:.0f}s", flush=True)
for q in a.query:
t = time.perf_counter()
res = gr.search([q], a.k)[0]
print(f"\n{q} ({(time.perf_counter() - t) * 1000:.0f} ms)")
for i, (u, lp, _) in enumerate(res, 1):
print(f" {i:2d}. {np.exp(lp):.3f} {u}")
if a.check and not check(gr):
raise SystemExit(1)
if __name__ == "__main__":
main()
|