logitgate 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,330 @@
1
+ """
2
+ Native PyTorch implementation of NVIDIA-Nemotron-Labs Dory architecture.
3
+
4
+ Dory is a normalized latent-recurrent language model (nGPT backbone) featuring:
5
+ - Layer stack partitioned into Input (16 layers), Recurrent (40 layers), and Output (16 layers)
6
+ - Elastic recurrent layer-looping (num_loops = 1, 2, 4) with hidden-state reinjection
7
+ - Interleaved sliding-window attention (SWA) and full-attention layers
8
+ - Exact nGPT unit-norm normalization (justnorm) and spherical linear interpolation (slerp)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import torch
19
+ import torch.nn.functional as F
20
+ from torch import nn
21
+
22
+ EPS = 1e-6
23
+
24
+
25
+ def justnorm(x: torch.Tensor, dim: int = -1) -> torch.Tensor:
26
+ """NGPT normalization: x * rsqrt(sum(x^2) + eps), fp32 accumulation."""
27
+ xf = x.float()
28
+ y = xf * torch.rsqrt(xf.pow(2).sum(dim=dim, keepdim=True) + EPS)
29
+ return y.to(x.dtype)
30
+
31
+
32
+ def slerp_norm(a: torch.Tensor, b: torch.Tensor, lr: torch.Tensor) -> torch.Tensor:
33
+ """Spherical linear interpolation between normalized vectors a and b."""
34
+ ya, yb = justnorm(a).float(), justnorm(b).float()
35
+ return justnorm(ya + lr.float() * (yb - ya)).to(a.dtype)
36
+
37
+
38
+ def slerp_alpha(
39
+ residual: torch.Tensor,
40
+ out: torch.Tensor,
41
+ alpha: torch.Tensor,
42
+ alpha_scale: float,
43
+ ) -> torch.Tensor:
44
+ """Calculates slerp residual connection scaled by learned alpha parameter."""
45
+ lr = (alpha.float() * alpha_scale).abs()
46
+ return slerp_norm(residual, out, lr)
47
+
48
+
49
+ def rotate_half(x: torch.Tensor) -> torch.Tensor:
50
+ """Rotates half the hidden dimensions of the input for NeoX-style RoPE."""
51
+ x1 = x[..., : x.shape[-1] // 2]
52
+ x2 = x[..., x.shape[-1] // 2 :]
53
+ return torch.cat((-x2, x1), dim=-1)
54
+
55
+
56
+ def apply_rotary_pos_emb(
57
+ q: torch.Tensor,
58
+ k: torch.Tensor,
59
+ cos: torch.Tensor,
60
+ sin: torch.Tensor,
61
+ ) -> tuple[torch.Tensor, torch.Tensor]:
62
+ """Applies rotary position embeddings to query and key tensors."""
63
+ q_rot = (q * cos) + (rotate_half(q) * sin)
64
+ k_rot = (k * cos) + (rotate_half(k) * sin)
65
+ return q_rot, k_rot
66
+
67
+
68
+ class DoryRotaryEmbedding(nn.Module):
69
+ """Precomputes rotary position embeddings for standard and long context."""
70
+
71
+ def __init__(self, dim: int = 256, base: float = 10000.0) -> None:
72
+ super().__init__()
73
+ self.dim = dim
74
+ self.base = base
75
+ inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2).float() / dim))
76
+ self.register_buffer("inv_freq", inv_freq, persistent=False)
77
+
78
+ def forward(
79
+ self,
80
+ seq_len: int,
81
+ device: torch.device,
82
+ dtype: torch.dtype,
83
+ ) -> tuple[torch.Tensor, torch.Tensor]:
84
+ t = torch.arange(seq_len, device=device, dtype=self.inv_freq.dtype)
85
+ freqs = torch.outer(t, self.inv_freq.to(device))
86
+ emb = torch.cat((freqs, freqs), dim=-1)
87
+ cos = emb.cos()[None, None, :, :].to(dtype)
88
+ sin = emb.sin()[None, None, :, :].to(dtype)
89
+ return cos, sin
90
+
91
+
92
+ class DoryAttention(nn.Module):
93
+ """GQA Attention layer with nGPT qk-norm, output gating, and slerp residual."""
94
+
95
+ def __init__(self, layer_idx: int, is_sliding: bool = True) -> None:
96
+ super().__init__()
97
+ self.layer_idx = layer_idx
98
+ self.is_sliding = is_sliding
99
+ self.hidden_size = 2560
100
+ self.num_heads = 16
101
+ self.num_kv_heads = 4
102
+ self.head_dim = 256
103
+ self.q_size = 16 * 256
104
+ self.kv_size = 4 * 256
105
+ self.std = 0.0177
106
+ self._sqk_factor = (256 ** 0.5) / self.std
107
+ g0 = 2560 ** 0.5
108
+ self._s_gate_factor = (g0 ** 0.5) / self.std
109
+ self._alpha_scale = 0.05 / self.std
110
+
111
+ self.q_proj = nn.Linear(self.hidden_size, self.q_size, bias=False)
112
+ self.k_proj = nn.Linear(self.hidden_size, self.kv_size, bias=False)
113
+ self.v_proj = nn.Linear(self.hidden_size, self.kv_size, bias=False)
114
+ self.g_proj = nn.Linear(self.hidden_size, self.q_size, bias=False)
115
+ self.o_proj = nn.Linear(self.q_size, self.hidden_size, bias=False)
116
+
117
+ self.sqk = nn.Parameter(torch.empty(self.q_size))
118
+ self.s_gate = nn.Parameter(torch.empty(self.q_size))
119
+ self.attn_alpha = nn.Parameter(torch.empty(self.hidden_size))
120
+ base = 10000.0 if is_sliding else 10000000.0
121
+ self.rotary = DoryRotaryEmbedding(dim=256, base=base)
122
+
123
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
124
+ batch, seq_len, _ = x.shape
125
+ residual = x
126
+
127
+ q = self.q_proj(x).view(batch, seq_len, self.num_heads, self.head_dim).transpose(1, 2)
128
+ k = self.k_proj(x).view(batch, seq_len, self.num_kv_heads, self.head_dim).transpose(1, 2)
129
+ v = self.v_proj(x).view(batch, seq_len, self.num_kv_heads, self.head_dim).transpose(1, 2)
130
+ gate = self.g_proj(x)
131
+
132
+ cos, sin = self.rotary(seq_len, device=x.device, dtype=x.dtype)
133
+ q, k = apply_rotary_pos_emb(q, k, cos, sin)
134
+
135
+ # nGPT qk-norm AFTER rope
136
+ sqk = (self.sqk.float() * self._sqk_factor).view(1, self.num_heads, 1, self.head_dim)
137
+ q = (sqk.square() * justnorm(q).float()).to(q.dtype)
138
+ k = justnorm(k).to(k.dtype)
139
+
140
+ # Replicate KV heads for Grouped Query Attention (4 -> 16)
141
+ k_rep = k.repeat_interleave(self.num_heads // self.num_kv_heads, dim=1)
142
+ v_rep = v.repeat_interleave(self.num_heads // self.num_kv_heads, dim=1)
143
+
144
+ attn_out = F.scaled_dot_product_attention(q, k_rep, v_rep, is_causal=True)
145
+ attn_out = attn_out.transpose(1, 2).contiguous().view(batch, seq_len, self.q_size)
146
+
147
+ # Output gate (sigmoid with s_gate scaling)
148
+ sg = (self.s_gate.float() * self._s_gate_factor).square()
149
+ attn_out = (attn_out.float() * torch.sigmoid(gate.float() * sg)).to(attn_out.dtype)
150
+ attn_out = justnorm(attn_out)
151
+ out = self.o_proj(attn_out)
152
+
153
+ return slerp_alpha(residual, out, self.attn_alpha, self._alpha_scale)
154
+
155
+
156
+ class DoryMLP(nn.Module):
157
+ """SwiGLU MLP layer with nGPT suv scaling and slerp residual."""
158
+
159
+ def __init__(self, layer_idx: int) -> None:
160
+ super().__init__()
161
+ self.layer_idx = layer_idx
162
+ self.hidden_size = 2560
163
+ self.ffn = 7168
164
+ self.std = 0.0177
165
+ self._suv_factor = 2560 ** 0.5
166
+ self._alpha_scale = 0.05 / self.std
167
+
168
+ self.gate_proj = nn.Linear(self.hidden_size, self.ffn, bias=False)
169
+ self.up_proj = nn.Linear(self.hidden_size, self.ffn, bias=False)
170
+ self.down_proj = nn.Linear(self.ffn, self.hidden_size, bias=False)
171
+
172
+ self.suv_gate = nn.Parameter(torch.empty(self.ffn))
173
+ self.suv_up = nn.Parameter(torch.empty(self.ffn))
174
+ self.mlp_alpha = nn.Parameter(torch.empty(self.hidden_size))
175
+
176
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
177
+ residual = x
178
+ g = self.gate_proj(x)
179
+ u = self.up_proj(x)
180
+ g = (g.float() * (self.suv_gate.float() * self._suv_factor)).to(g.dtype)
181
+ u = (u.float() * (self.suv_up.float() * self._suv_factor)).to(u.dtype)
182
+ m = (F.silu(g.float()) * u.float()).to(x.dtype)
183
+ m = justnorm(m)
184
+ out = self.down_proj(m)
185
+ return slerp_alpha(residual, out, self.mlp_alpha, self._alpha_scale)
186
+
187
+
188
+ class DoryForCausalLM(nn.Module):
189
+ """
190
+ Dory-4B Causal Language Model with elastic recurrent layer-looping.
191
+
192
+ Architecture:
193
+ - 72 physical layers (16 Input + 40 Recurrent + 16 Output)
194
+ - Loops recurrent layers `num_loops` times per token
195
+ """
196
+
197
+ def __init__(self, num_loops: int = 2) -> None:
198
+ super().__init__()
199
+ self.num_loops = num_loops
200
+ self.vocab_size = 131072
201
+ self.hidden_size = 2560
202
+ self.n_input_layers = 16
203
+ self.n_recurrent_layers = 40
204
+ self.n_output_layers = 16
205
+ self.std = 0.0177
206
+
207
+ self.embeddings = nn.Embedding(self.vocab_size, self.hidden_size)
208
+ self.layers = nn.ModuleList()
209
+ for i in range(72):
210
+ if i % 2 == 0:
211
+ is_full = (i % 8 == 6)
212
+ self.layers.append(DoryAttention(i, is_sliding=not is_full))
213
+ else:
214
+ self.layers.append(DoryMLP(i))
215
+
216
+ self.lm_head = nn.Linear(self.hidden_size, self.vocab_size, bias=False)
217
+ self.logit_scale = nn.Parameter(torch.empty(self.vocab_size))
218
+
219
+ def forward(
220
+ self,
221
+ input_ids: torch.Tensor,
222
+ num_loops: int | None = None,
223
+ attention_mask: torch.Tensor | None = None,
224
+ **kwargs: Any,
225
+ ) -> torch.Tensor:
226
+ """Executes forward pass with `num_loops` recurrent passes on hidden states."""
227
+ k = num_loops if num_loops is not None else self.num_loops
228
+ h = self.embeddings(input_ids)
229
+
230
+ n_in = self.n_input_layers
231
+ n_rec = self.n_recurrent_layers
232
+
233
+ # Input block (16 layers)
234
+ for layer in self.layers[:n_in]:
235
+ h = layer(h)
236
+ input_h = h
237
+
238
+ # Recurrent block (40 layers, looped k times with reinjection)
239
+ state = input_h
240
+ for loop_idx in range(k):
241
+ combined = justnorm(state + input_h)
242
+ h = combined
243
+ for layer in self.layers[n_in : n_in + n_rec]:
244
+ h = layer(h)
245
+ state = justnorm(h)
246
+
247
+ # Output block (16 layers)
248
+ h = state
249
+ for layer in self.layers[n_in + n_rec:]:
250
+ h = layer(h)
251
+
252
+ # LM Head with learned logit_scale on last token for next-token prediction
253
+ # (avoid allocating vocab_size * seq_len float32 tensors on long contexts)
254
+ last_h = h if kwargs.get("return_full_logits") else h[:, -1:, :]
255
+ logits = self.lm_head(last_h)
256
+ logits = logits.float() * (self.logit_scale.float() / self.std)
257
+ return logits
258
+
259
+ @torch.no_grad()
260
+ def generate(
261
+ self,
262
+ input_ids: torch.Tensor,
263
+ max_new_tokens: int = 256,
264
+ num_loops: int | None = None,
265
+ eos_token_ids: list[int] | None = None,
266
+ **kwargs: Any,
267
+ ) -> torch.Tensor:
268
+ """Autoregressively generates new tokens."""
269
+ eos = eos_token_ids or [2, 11]
270
+ curr = input_ids
271
+ for _ in range(max_new_tokens):
272
+ logits = self.forward(curr, num_loops=num_loops)
273
+ next_token = torch.argmax(logits[:, -1, :], dim=-1, keepdim=True)
274
+ curr = torch.cat([curr, next_token], dim=-1)
275
+ if next_token.item() in eos:
276
+ break
277
+ return curr
278
+
279
+
280
+ def load_dory_model(
281
+ model_name_or_path: str = "nvidia/NVIDIA-Nemotron-Labs-Dory1.0-4B-BF16",
282
+ device: str = "cuda:0",
283
+ num_loops: int = 2,
284
+ torch_dtype: torch.dtype = torch.bfloat16,
285
+ ) -> DoryForCausalLM:
286
+ """Loads Dory model weights from safetensors into DoryForCausalLM."""
287
+ from huggingface_hub import hf_hub_download
288
+ from safetensors import safe_open
289
+
290
+ t0 = time.perf_counter()
291
+ if os.path.exists(model_name_or_path) and (Path(model_name_or_path) / "model.safetensors").exists():
292
+ safetensor_path = str(Path(model_name_or_path) / "model.safetensors")
293
+ elif os.path.exists(model_name_or_path) and model_name_or_path.endswith(".safetensors"):
294
+ safetensor_path = model_name_or_path
295
+ else:
296
+ safetensor_path = hf_hub_download(model_name_or_path, "model.safetensors")
297
+
298
+ model = DoryForCausalLM(num_loops=num_loops).to(dtype=torch_dtype)
299
+
300
+ with safe_open(safetensor_path, framework="pt") as f:
301
+ state_dict: dict[str, torch.Tensor] = {}
302
+ state_dict["embeddings.weight"] = f.get_tensor("backbone.embeddings.weight")
303
+ state_dict["lm_head.weight"] = f.get_tensor("lm_head.weight")
304
+ state_dict["logit_scale"] = f.get_tensor("lm_head.logit_scale")
305
+
306
+ for i in range(72):
307
+ prefix = f"backbone.layers.{i}."
308
+ if i % 2 == 0:
309
+ state_dict[f"layers.{i}.attn_alpha"] = f.get_tensor(prefix + "attn_alpha")
310
+ state_dict[f"layers.{i}.q_proj.weight"] = f.get_tensor(prefix + "mixer.q_proj.weight")
311
+ state_dict[f"layers.{i}.k_proj.weight"] = f.get_tensor(prefix + "mixer.k_proj.weight")
312
+ state_dict[f"layers.{i}.v_proj.weight"] = f.get_tensor(prefix + "mixer.v_proj.weight")
313
+ state_dict[f"layers.{i}.g_proj.weight"] = f.get_tensor(prefix + "mixer.g_proj.weight")
314
+ state_dict[f"layers.{i}.o_proj.weight"] = f.get_tensor(prefix + "mixer.o_proj.weight")
315
+ state_dict[f"layers.{i}.sqk"] = f.get_tensor(prefix + "mixer.sqk")
316
+ state_dict[f"layers.{i}.s_gate"] = f.get_tensor(prefix + "mixer.s_gate")
317
+ else:
318
+ state_dict[f"layers.{i}.mlp_alpha"] = f.get_tensor(prefix + "mlp_alpha")
319
+ state_dict[f"layers.{i}.gate_proj.weight"] = f.get_tensor(prefix + "mixer.gate_proj.weight")
320
+ state_dict[f"layers.{i}.up_proj.weight"] = f.get_tensor(prefix + "mixer.up_proj.weight")
321
+ state_dict[f"layers.{i}.down_proj.weight"] = f.get_tensor(prefix + "mixer.down_proj.weight")
322
+ state_dict[f"layers.{i}.suv_gate"] = f.get_tensor(prefix + "mixer.suv_gate")
323
+ state_dict[f"layers.{i}.suv_up"] = f.get_tensor(prefix + "mixer.suv_up")
324
+
325
+ model.load_state_dict(state_dict, strict=True)
326
+ model = model.to(device)
327
+ model.eval()
328
+ elapsed = time.perf_counter() - t0
329
+ print(f"Loaded Dory model ({num_loops} loops) onto {device} in {elapsed:.2f}s", flush=True)
330
+ return model
@@ -0,0 +1,352 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import math
5
+ from typing import Any
6
+
7
+ import httpx
8
+ from typing_extensions import Self
9
+
10
+ from logitgate.backends.base import (
11
+ GenerativeBackend,
12
+ LogitBackend,
13
+ strip_reasoning_blocks,
14
+ )
15
+
16
+
17
+ class LlamaCppBackend(LogitBackend, GenerativeBackend):
18
+ """
19
+ Backend for llama.cpp supporting:
20
+ Mode A: In-process llama_cpp.Llama instance (via create_chat_completion)
21
+ Mode B: llama-server HTTP endpoint (/v1/chat/completions with /completion fallback)
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ llm: Any = None,
27
+ base_url: str = "http://localhost:8080",
28
+ model: str | None = None,
29
+ timeout: float = 30.0,
30
+ client: httpx.Client | None = None,
31
+ async_client: httpx.AsyncClient | None = None,
32
+ ) -> None:
33
+ self.llm = llm
34
+ self.base_url = base_url.rstrip("/") if base_url else "http://localhost:8080"
35
+ self.model = model
36
+ self.timeout = timeout
37
+ self._client = client
38
+ self._async_client = async_client
39
+ self._use_completion_fallback = False
40
+
41
+ def __repr__(self) -> str:
42
+ return f"LlamaCppBackend(model={self.model!r}, base_url='{self.base_url}')"
43
+
44
+ @property
45
+ def client(self) -> httpx.Client:
46
+ if self._client is None or self._client.is_closed:
47
+ self._client = httpx.Client(timeout=self.timeout)
48
+ return self._client
49
+
50
+ @property
51
+ def async_client(self) -> httpx.AsyncClient:
52
+ if self._async_client is None or self._async_client.is_closed:
53
+ self._async_client = httpx.AsyncClient(timeout=self.timeout)
54
+ return self._async_client
55
+
56
+ def close(self) -> None:
57
+ """Closes the synchronous HTTP client session if initialized."""
58
+ if self._client is not None and not self._client.is_closed:
59
+ self._client.close()
60
+
61
+ async def aclose(self) -> None:
62
+ """Closes the asynchronous HTTP client session if initialized."""
63
+ if self._async_client is not None and not self._async_client.is_closed:
64
+ await self._async_client.aclose()
65
+
66
+ def __enter__(self) -> Self:
67
+ return self
68
+
69
+ def __exit__(self, *args: object) -> None:
70
+ self.close()
71
+
72
+ async def __aenter__(self) -> Self:
73
+ return self
74
+
75
+ async def __aexit__(self, *args: object) -> None:
76
+ await self.aclose()
77
+
78
+ @staticmethod
79
+ def _extract_chat_top_logprobs(data: dict[str, Any]) -> list[dict[str, Any]]:
80
+ choices = data.get("choices", [])
81
+ if not choices:
82
+ return []
83
+ logprobs = choices[0].get("logprobs")
84
+ if not logprobs or not isinstance(logprobs, dict):
85
+ return []
86
+ content = logprobs.get("content")
87
+ if content and isinstance(content, list) and len(content) > 0:
88
+ top_lps = content[0].get("top_logprobs", [])
89
+ return [
90
+ {"token": item.get("token", ""), "logprob": float(item.get("logprob", 0.0))}
91
+ for item in top_lps
92
+ if isinstance(item, dict) and "token" in item
93
+ ]
94
+ top_lps = logprobs.get("top_logprobs")
95
+ if top_lps and isinstance(top_lps, list):
96
+ first = top_lps[0] if isinstance(top_lps[0], list) else top_lps
97
+ return [
98
+ {"token": item.get("token", ""), "logprob": float(item.get("logprob", 0.0))}
99
+ for item in first
100
+ if isinstance(item, dict) and "token" in item
101
+ ]
102
+ return []
103
+
104
+ @staticmethod
105
+ def _extract_completion_logprobs(data: dict[str, Any]) -> list[dict[str, Any]]:
106
+ comp_probs = data.get("completion_probabilities", [])
107
+ if comp_probs and isinstance(comp_probs, list):
108
+ first = comp_probs[0]
109
+ probs = first.get("probs", []) if isinstance(first, dict) else []
110
+ results = []
111
+ for p in probs:
112
+ if not isinstance(p, dict):
113
+ continue
114
+ tok = p.get("tok_str", p.get("token", ""))
115
+ if "logprob" in p:
116
+ lp = float(p["logprob"])
117
+ elif "prob" in p:
118
+ lp = math.log(max(float(p["prob"]), 1e-12))
119
+ else:
120
+ lp = 0.0
121
+ results.append({"token": tok, "logprob": lp})
122
+ return results
123
+ return []
124
+
125
+ def _in_process_query_top_logprobs(
126
+ self,
127
+ prompt: str,
128
+ top_k: int = 10,
129
+ allowed_tokens: list[str] | None = None,
130
+ **kwargs: Any,
131
+ ) -> list[dict[str, Any]]:
132
+ messages = [
133
+ {
134
+ "role": "system",
135
+ "content": "Respond with a single character only. Do not think or explain.",
136
+ },
137
+ {"role": "user", "content": prompt},
138
+ ]
139
+ res = self.llm.create_chat_completion(
140
+ messages=messages,
141
+ max_tokens=1,
142
+ temperature=0.0,
143
+ logprobs=True,
144
+ top_logprobs=top_k,
145
+ **kwargs,
146
+ )
147
+ return self._extract_chat_top_logprobs(res)
148
+
149
+ def _in_process_generate(self, prompt: str, max_tokens: int | None = None, **kwargs: Any) -> tuple[str, int]:
150
+ messages = [{"role": "user", "content": prompt}]
151
+ call_kwargs: dict[str, Any] = {"temperature": 0.0}
152
+ if max_tokens is not None:
153
+ call_kwargs["max_tokens"] = max_tokens
154
+ call_kwargs.update(kwargs)
155
+ res = self.llm.create_chat_completion(
156
+ messages=messages,
157
+ **call_kwargs,
158
+ )
159
+ choices = res.get("choices", [])
160
+ content = choices[0].get("message", {}).get("content", "") if choices else ""
161
+ tokens = res.get("usage", {}).get("completion_tokens", 0)
162
+ return strip_reasoning_blocks(content), tokens
163
+
164
+ def build_chat_payload(
165
+ self,
166
+ prompt: str,
167
+ top_k: int = 10,
168
+ allowed_tokens: list[str] | None = None,
169
+ **kwargs: Any,
170
+ ) -> dict[str, Any]:
171
+ payload: dict[str, Any] = {
172
+ "model": self.model or "default",
173
+ "messages": [
174
+ {
175
+ "role": "system",
176
+ "content": "Respond with a single character only. Do not think or explain.",
177
+ },
178
+ {"role": "user", "content": prompt},
179
+ ],
180
+ "max_tokens": 1,
181
+ "temperature": 0.0,
182
+ "logprobs": True,
183
+ "top_logprobs": top_k,
184
+ }
185
+ if "logit_bias" in kwargs:
186
+ payload["logit_bias"] = kwargs["logit_bias"]
187
+ for k, v in kwargs.items():
188
+ if k not in payload and k != "logit_bias":
189
+ payload[k] = v
190
+ return payload
191
+
192
+ def build_completion_payload(
193
+ self,
194
+ prompt: str,
195
+ top_k: int = 10,
196
+ allowed_tokens: list[str] | None = None,
197
+ **kwargs: Any,
198
+ ) -> dict[str, Any]:
199
+ payload: dict[str, Any] = {
200
+ "prompt": prompt,
201
+ "n_predict": 1,
202
+ "temperature": 0.0,
203
+ "n_probs": top_k,
204
+ }
205
+ for k, v in kwargs.items():
206
+ if k not in payload:
207
+ payload[k] = v
208
+ return payload
209
+
210
+ def query_top_logprobs(
211
+ self,
212
+ prompt: str,
213
+ top_k: int = 10,
214
+ allowed_tokens: list[str] | None = None,
215
+ **kwargs: Any,
216
+ ) -> list[dict[str, Any]]:
217
+ if self.llm is not None:
218
+ return self._in_process_query_top_logprobs(
219
+ prompt, top_k=top_k, allowed_tokens=allowed_tokens, **kwargs
220
+ )
221
+
222
+ if not self._use_completion_fallback:
223
+ url = f"{self.base_url}/v1/chat/completions"
224
+ payload = self.build_chat_payload(
225
+ prompt, top_k=top_k, allowed_tokens=allowed_tokens, **kwargs
226
+ )
227
+ resp = self.client.post(url, json=payload)
228
+ if resp.status_code == 404:
229
+ self._use_completion_fallback = True
230
+ else:
231
+ resp.raise_for_status()
232
+ return self._extract_chat_top_logprobs(resp.json())
233
+
234
+ # Fallback to /completion
235
+ url = f"{self.base_url}/completion"
236
+ payload = self.build_completion_payload(
237
+ prompt, top_k=top_k, allowed_tokens=allowed_tokens, **kwargs
238
+ )
239
+ resp = self.client.post(url, json=payload)
240
+ resp.raise_for_status()
241
+ return self._extract_completion_logprobs(resp.json())
242
+
243
+ async def aquery_top_logprobs(
244
+ self,
245
+ prompt: str,
246
+ top_k: int = 10,
247
+ allowed_tokens: list[str] | None = None,
248
+ **kwargs: Any,
249
+ ) -> list[dict[str, Any]]:
250
+ if self.llm is not None:
251
+ loop = asyncio.get_running_loop()
252
+ return await loop.run_in_executor(
253
+ None, self._in_process_query_top_logprobs, prompt, top_k, allowed_tokens
254
+ )
255
+
256
+ if not self._use_completion_fallback:
257
+ url = f"{self.base_url}/v1/chat/completions"
258
+ payload = self.build_chat_payload(
259
+ prompt, top_k=top_k, allowed_tokens=allowed_tokens, **kwargs
260
+ )
261
+ resp = await self.async_client.post(url, json=payload)
262
+ if resp.status_code == 404:
263
+ self._use_completion_fallback = True
264
+ else:
265
+ resp.raise_for_status()
266
+ return self._extract_chat_top_logprobs(resp.json())
267
+
268
+ # Fallback to /completion
269
+ url = f"{self.base_url}/completion"
270
+ payload = self.build_completion_payload(
271
+ prompt, top_k=top_k, allowed_tokens=allowed_tokens, **kwargs
272
+ )
273
+ resp = await self.async_client.post(url, json=payload)
274
+ resp.raise_for_status()
275
+ return self._extract_completion_logprobs(resp.json())
276
+
277
+ def generate(self, prompt: str, max_tokens: int | None = None, **kwargs: Any) -> tuple[str, int]:
278
+ if self.llm is not None:
279
+ return self._in_process_generate(prompt, max_tokens=max_tokens, **kwargs)
280
+
281
+ if not self._use_completion_fallback:
282
+ url = f"{self.base_url}/v1/chat/completions"
283
+ payload: dict[str, Any] = {
284
+ "model": self.model or "default",
285
+ "messages": [{"role": "user", "content": prompt}],
286
+ "temperature": 0.0,
287
+ }
288
+ if max_tokens is not None:
289
+ payload["max_tokens"] = max_tokens
290
+ payload.update(kwargs)
291
+ resp = self.client.post(url, json=payload)
292
+ if resp.status_code == 404:
293
+ self._use_completion_fallback = True
294
+ else:
295
+ resp.raise_for_status()
296
+ data = resp.json()
297
+ choices = data.get("choices", [])
298
+ content = choices[0].get("message", {}).get("content", "") if choices else ""
299
+ tokens = data.get("usage", {}).get("completion_tokens", 0)
300
+ return strip_reasoning_blocks(content), tokens
301
+
302
+ # Fallback to /completion
303
+ url = f"{self.base_url}/completion"
304
+ payload = {"prompt": prompt, "temperature": 0.0}
305
+ if max_tokens is not None:
306
+ payload["n_predict"] = max_tokens
307
+ payload.update(kwargs)
308
+ resp = self.client.post(url, json=payload)
309
+ resp.raise_for_status()
310
+ data = resp.json()
311
+ content = data.get("content", "")
312
+ tokens = data.get("tokens_predicted", data.get("tokens_evaluated", 0))
313
+ return strip_reasoning_blocks(content), tokens
314
+
315
+ async def agenerate(self, prompt: str, max_tokens: int | None = None, **kwargs: Any) -> tuple[str, int]:
316
+ if self.llm is not None:
317
+ loop = asyncio.get_running_loop()
318
+ return await loop.run_in_executor(None, lambda: self._in_process_generate(prompt, max_tokens=max_tokens, **kwargs))
319
+
320
+ if not self._use_completion_fallback:
321
+ url = f"{self.base_url}/v1/chat/completions"
322
+ payload: dict[str, Any] = {
323
+ "model": self.model or "default",
324
+ "messages": [{"role": "user", "content": prompt}],
325
+ "temperature": 0.0,
326
+ }
327
+ if max_tokens is not None:
328
+ payload["max_tokens"] = max_tokens
329
+ payload.update(kwargs)
330
+ resp = await self.async_client.post(url, json=payload)
331
+ if resp.status_code == 404:
332
+ self._use_completion_fallback = True
333
+ else:
334
+ resp.raise_for_status()
335
+ data = resp.json()
336
+ choices = data.get("choices", [])
337
+ content = choices[0].get("message", {}).get("content", "") if choices else ""
338
+ tokens = data.get("usage", {}).get("completion_tokens", 0)
339
+ return strip_reasoning_blocks(content), tokens
340
+
341
+ # Fallback to /completion
342
+ url = f"{self.base_url}/completion"
343
+ payload = {"prompt": prompt, "temperature": 0.0}
344
+ if max_tokens is not None:
345
+ payload["n_predict"] = max_tokens
346
+ payload.update(kwargs)
347
+ resp = await self.async_client.post(url, json=payload)
348
+ resp.raise_for_status()
349
+ data = resp.json()
350
+ content = data.get("content", "")
351
+ tokens = data.get("tokens_predicted", data.get("tokens_evaluated", 0))
352
+ return strip_reasoning_blocks(content), tokens