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.
- logitgate/__init__.py +103 -0
- logitgate/backends/__init__.py +25 -0
- logitgate/backends/base.py +259 -0
- logitgate/backends/dory_pytorch.py +330 -0
- logitgate/backends/llamacpp.py +352 -0
- logitgate/backends/ollama.py +286 -0
- logitgate/backends/openai_compat.py +195 -0
- logitgate/backends/recurrent.py +245 -0
- logitgate/cascade.py +394 -0
- logitgate/cli.py +264 -0
- logitgate/config.py +172 -0
- logitgate/debiasing/__init__.py +35 -0
- logitgate/debiasing/inversion.py +180 -0
- logitgate/debiasing/permutation.py +287 -0
- logitgate/debiasing/softmax.py +98 -0
- logitgate/gates/__init__.py +14 -0
- logitgate/gates/ast_gate.py +544 -0
- logitgate/gates/base.py +68 -0
- logitgate/gates/judge_gate.py +318 -0
- logitgate/gates/logit_gate.py +951 -0
- logitgate/integrations/__init__.py +10 -0
- logitgate/integrations/deepeval.py +212 -0
- logitgate/integrations/langgraph.py +30 -0
- logitgate/integrations/pytest_plugin.py +68 -0
- logitgate/models.py +109 -0
- logitgate/py.typed +0 -0
- logitgate/snapshot.py +224 -0
- logitgate-0.2.0.dist-info/METADATA +1236 -0
- logitgate-0.2.0.dist-info/RECORD +33 -0
- logitgate-0.2.0.dist-info/WHEEL +5 -0
- logitgate-0.2.0.dist-info/entry_points.txt +5 -0
- logitgate-0.2.0.dist-info/licenses/LICENSE +201 -0
- logitgate-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|