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,286 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+ from typing_extensions import Self
7
+
8
+ from logitgate.backends.base import (
9
+ GenerativeBackend,
10
+ LogitBackend,
11
+ strip_reasoning_blocks,
12
+ )
13
+ from logitgate.config import LogitGateDefaults
14
+
15
+
16
+ class OllamaBackend(LogitBackend, GenerativeBackend):
17
+ """
18
+ Ollama backend supporting first-token logprob extraction with reasoning suppression
19
+ (think: False), persistent connection pooling, and generative diagnostic critiques.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ base_url: str = "http://localhost:11434",
25
+ model: str = "qwen2.5-coder:14b",
26
+ timeout: float = 30.0,
27
+ num_ctx: int | None = None,
28
+ options: dict[str, Any] | None = None,
29
+ keep_alive: str | int | None = None,
30
+ client: httpx.Client | None = None,
31
+ async_client: httpx.AsyncClient | None = None,
32
+ ) -> None:
33
+ self.base_url = base_url.rstrip("/")
34
+ self.model = model
35
+ self.timeout = timeout
36
+ eff_num_ctx = num_ctx if num_ctx is not None else (LogitGateDefaults.num_ctx or 16384)
37
+ self.num_ctx = eff_num_ctx
38
+ self.options = dict(options) if options else {}
39
+ self.keep_alive = keep_alive
40
+ self._client = client
41
+ self._async_client = async_client
42
+
43
+ def __repr__(self) -> str:
44
+ return f"OllamaBackend(model='{self.model}', base_url='{self.base_url}')"
45
+
46
+ @property
47
+ def client(self) -> httpx.Client:
48
+ if self._client is None or self._client.is_closed:
49
+ self._client = httpx.Client(timeout=self.timeout)
50
+ return self._client
51
+
52
+ @property
53
+ def async_client(self) -> httpx.AsyncClient:
54
+ if self._async_client is None or self._async_client.is_closed:
55
+ self._async_client = httpx.AsyncClient(timeout=self.timeout)
56
+ return self._async_client
57
+
58
+ def close(self) -> None:
59
+ """Closes the synchronous HTTP client session if initialized."""
60
+ if self._client is not None and not self._client.is_closed:
61
+ self._client.close()
62
+
63
+ async def aclose(self) -> None:
64
+ """Closes the asynchronous HTTP client session if initialized."""
65
+ if self._async_client is not None and not self._async_client.is_closed:
66
+ await self._async_client.aclose()
67
+
68
+ def __enter__(self) -> Self:
69
+ return self
70
+
71
+ def __exit__(self, *args: object) -> None:
72
+ self.close()
73
+
74
+ async def __aenter__(self) -> Self:
75
+ return self
76
+
77
+ async def __aexit__(self, *args: object) -> None:
78
+ await self.aclose()
79
+
80
+ def build_query_payload(
81
+ self,
82
+ prompt: str,
83
+ top_k: int = 10,
84
+ allowed_tokens: list[str] | None = None,
85
+ **kwargs: Any,
86
+ ) -> dict[str, Any]:
87
+ """Constructs the chat completion payload ensuring think: False."""
88
+ messages = [
89
+ {
90
+ "role": "system",
91
+ "content": "Respond with a single character only. Do not think or explain.",
92
+ },
93
+ {"role": "user", "content": prompt},
94
+ ]
95
+ opts: dict[str, Any] = {
96
+ "num_predict": 1,
97
+ "temperature": 0.0,
98
+ "num_ctx": self.num_ctx,
99
+ }
100
+ if self.options:
101
+ opts.update(self.options)
102
+ payload: dict[str, Any] = {
103
+ "model": self.model,
104
+ "messages": messages,
105
+ "stream": False,
106
+ "think": False,
107
+ "logprobs": True,
108
+ "top_logprobs": top_k,
109
+ "options": opts,
110
+ }
111
+ if self.keep_alive is not None:
112
+ payload["keep_alive"] = self.keep_alive
113
+ for k, v in kwargs.items():
114
+ if k == "options" and isinstance(v, dict):
115
+ payload["options"].update(v)
116
+ elif k not in payload:
117
+ payload[k] = v
118
+ return payload
119
+
120
+ def _extract_top_logprobs_from_response(self, data: dict[str, Any]) -> list[dict[str, Any]]:
121
+ lp = data.get("logprobs") or data.get("message", {}).get("logprobs")
122
+ if not lp or not isinstance(lp, list):
123
+ return []
124
+ first = lp[0]
125
+ if isinstance(first, dict):
126
+ if "top_logprobs" in first:
127
+ return first["top_logprobs"]
128
+ if "token" in first:
129
+ return lp
130
+ return []
131
+
132
+ def query_top_logprobs(
133
+ self,
134
+ prompt: str,
135
+ top_k: int = 10,
136
+ allowed_tokens: list[str] | None = None,
137
+ **kwargs: Any,
138
+ ) -> list[dict[str, Any]]:
139
+ payload = self.build_query_payload(
140
+ prompt, top_k=top_k, allowed_tokens=allowed_tokens, **kwargs
141
+ )
142
+ resp = self.client.post(f"{self.base_url}/api/chat", json=payload)
143
+ resp.raise_for_status()
144
+ data = resp.json()
145
+ return self._extract_top_logprobs_from_response(data)
146
+
147
+ async def aquery_top_logprobs(
148
+ self,
149
+ prompt: str,
150
+ top_k: int = 10,
151
+ allowed_tokens: list[str] | None = None,
152
+ **kwargs: Any,
153
+ ) -> list[dict[str, Any]]:
154
+ payload = self.build_query_payload(
155
+ prompt, top_k=top_k, allowed_tokens=allowed_tokens, **kwargs
156
+ )
157
+ resp = await self.async_client.post(f"{self.base_url}/api/chat", json=payload)
158
+ resp.raise_for_status()
159
+ data = resp.json()
160
+ return self._extract_top_logprobs_from_response(data)
161
+
162
+ def generate(self, prompt: str, max_tokens: int | None = None, **kwargs: Any) -> tuple[str, int]:
163
+ opts: dict[str, Any] = {
164
+ "temperature": 0.0,
165
+ "num_ctx": self.num_ctx,
166
+ }
167
+ if max_tokens is not None:
168
+ opts["num_predict"] = max_tokens
169
+ if self.options:
170
+ opts.update(self.options)
171
+ think_default = max_tokens is None or max_tokens >= 256
172
+ think_val = kwargs.pop("think", think_default)
173
+ if think_val is None:
174
+ think_val = think_default
175
+ payload: dict[str, Any] = {
176
+ "model": self.model,
177
+ "messages": [{"role": "user", "content": prompt}],
178
+ "stream": False,
179
+ "think": think_val,
180
+ "options": opts,
181
+ }
182
+ if self.keep_alive is not None:
183
+ payload["keep_alive"] = self.keep_alive
184
+ for k, v in kwargs.items():
185
+ if k == "options" and isinstance(v, dict):
186
+ payload["options"].update(v)
187
+ else:
188
+ payload[k] = v
189
+ resp = self.client.post(f"{self.base_url}/api/chat", json=payload)
190
+ if resp.status_code == 400 and think_val:
191
+ # Fallback for non-thinking models (e.g. qwen2.5, llama3) that reject think=True
192
+ payload["think"] = False
193
+ think_val = False
194
+ resp = self.client.post(f"{self.base_url}/api/chat", json=payload)
195
+ resp.raise_for_status()
196
+ data = resp.json()
197
+ content = data.get("message", {}).get("content", "")
198
+ tokens = data.get("eval_count", 0)
199
+ visible_text = strip_reasoning_blocks(content)
200
+
201
+ # Mid-Thought Truncation Safety Net: True Continuation
202
+ # If thinking was enabled and 100% of generated tokens were consumed inside <think>,
203
+ # visible_text is empty while raw content was non-empty. Close </think> and continue
204
+ # with think=False so all max_tokens go to visible diagnostic output while preserving CoT.
205
+ if not visible_text and content and think_val:
206
+ raw_thinking = content.rstrip()
207
+ assistant_prefill = raw_thinking + "\n</think>\n"
208
+ continuation_opts = dict(opts)
209
+ if max_tokens is not None:
210
+ continuation_opts["num_predict"] = max_tokens
211
+ continuation_payload = dict(payload)
212
+ continuation_payload["messages"] = [
213
+ {"role": "user", "content": prompt},
214
+ {"role": "assistant", "content": assistant_prefill},
215
+ ]
216
+ continuation_payload["think"] = False
217
+ continuation_payload["options"] = continuation_opts
218
+ resp_cont = self.client.post(f"{self.base_url}/api/chat", json=continuation_payload)
219
+ resp_cont.raise_for_status()
220
+ data_cont = resp_cont.json()
221
+ content_cont = data_cont.get("message", {}).get("content", "")
222
+ tokens_cont = data_cont.get("eval_count", 0)
223
+ return strip_reasoning_blocks(content_cont), tokens + tokens_cont
224
+
225
+ return visible_text, tokens
226
+
227
+ async def agenerate(self, prompt: str, max_tokens: int | None = None, **kwargs: Any) -> tuple[str, int]:
228
+ opts: dict[str, Any] = {
229
+ "temperature": 0.0,
230
+ "num_ctx": self.num_ctx,
231
+ }
232
+ if max_tokens is not None:
233
+ opts["num_predict"] = max_tokens
234
+ if self.options:
235
+ opts.update(self.options)
236
+ think_default = max_tokens is None or max_tokens >= 256
237
+ think_val = kwargs.pop("think", think_default)
238
+ if think_val is None:
239
+ think_val = think_default
240
+ payload: dict[str, Any] = {
241
+ "model": self.model,
242
+ "messages": [{"role": "user", "content": prompt}],
243
+ "stream": False,
244
+ "think": think_val,
245
+ "options": opts,
246
+ }
247
+ if self.keep_alive is not None:
248
+ payload["keep_alive"] = self.keep_alive
249
+ for k, v in kwargs.items():
250
+ if k == "options" and isinstance(v, dict):
251
+ payload["options"].update(v)
252
+ else:
253
+ payload[k] = v
254
+ resp = await self.async_client.post(f"{self.base_url}/api/chat", json=payload)
255
+ if resp.status_code == 400 and think_val:
256
+ payload["think"] = False
257
+ think_val = False
258
+ resp = await self.async_client.post(f"{self.base_url}/api/chat", json=payload)
259
+ resp.raise_for_status()
260
+ data = resp.json()
261
+ content = data.get("message", {}).get("content", "")
262
+ tokens = data.get("eval_count", 0)
263
+ visible_text = strip_reasoning_blocks(content)
264
+
265
+ # Mid-Thought Truncation Safety Net: True Continuation
266
+ if not visible_text and content and think_val:
267
+ raw_thinking = content.rstrip()
268
+ assistant_prefill = raw_thinking + "\n</think>\n"
269
+ continuation_opts = dict(opts)
270
+ if max_tokens is not None:
271
+ continuation_opts["num_predict"] = max_tokens
272
+ continuation_payload = dict(payload)
273
+ continuation_payload["messages"] = [
274
+ {"role": "user", "content": prompt},
275
+ {"role": "assistant", "content": assistant_prefill},
276
+ ]
277
+ continuation_payload["think"] = False
278
+ continuation_payload["options"] = continuation_opts
279
+ resp_cont = await self.async_client.post(f"{self.base_url}/api/chat", json=continuation_payload)
280
+ resp_cont.raise_for_status()
281
+ data_cont = resp_cont.json()
282
+ content_cont = data_cont.get("message", {}).get("content", "")
283
+ tokens_cont = data_cont.get("eval_count", 0)
284
+ return strip_reasoning_blocks(content_cont), tokens + tokens_cont
285
+
286
+ return visible_text, tokens
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ import httpx
7
+ from typing_extensions import Self
8
+
9
+ from logitgate.backends.base import (
10
+ GenerativeBackend,
11
+ LogitBackend,
12
+ strip_reasoning_blocks,
13
+ )
14
+
15
+
16
+ class OpenAICompatBackend(LogitBackend, GenerativeBackend):
17
+ """
18
+ OpenAI-compatible backend (vLLM, Groq, OpenRouter, Together, OpenAI)
19
+ supporting first-token logprob extraction, persistent connection pooling,
20
+ and generative reviews with reasoning trace suppression.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ base_url: str = "https://api.openai.com/v1",
26
+ model: str = "gpt-4o-mini",
27
+ api_key: str | None = None,
28
+ timeout: float = 30.0,
29
+ logit_bias: dict[str, int | float] | None = None,
30
+ client: httpx.Client | None = None,
31
+ async_client: httpx.AsyncClient | None = None,
32
+ ) -> None:
33
+ self.base_url = base_url.rstrip("/")
34
+ self.model = model
35
+ self.api_key = api_key or os.environ.get("OPENAI_API_KEY", "sk-dummy")
36
+ self.timeout = timeout
37
+ self.logit_bias = logit_bias
38
+ self._client = client
39
+ self._async_client = async_client
40
+
41
+ def __repr__(self) -> str:
42
+ return f"OpenAICompatBackend(model='{self.model}', 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
+ def _headers(self) -> dict[str, str]:
79
+ headers = {"Content-Type": "application/json"}
80
+ if self.api_key:
81
+ headers["Authorization"] = f"Bearer {self.api_key}"
82
+ return headers
83
+
84
+ def build_query_payload(
85
+ self,
86
+ prompt: str,
87
+ top_k: int = 10,
88
+ allowed_tokens: list[str] | None = None,
89
+ **kwargs: Any,
90
+ ) -> dict[str, Any]:
91
+ payload: dict[str, Any] = {
92
+ "model": self.model,
93
+ "messages": [
94
+ {
95
+ "role": "system",
96
+ "content": "Respond with a single character only.",
97
+ },
98
+ {"role": "user", "content": prompt},
99
+ ],
100
+ "max_tokens": 1,
101
+ "temperature": 0.0,
102
+ "logprobs": True,
103
+ "top_logprobs": min(top_k, 20),
104
+ }
105
+ logit_bias = kwargs.get("logit_bias", self.logit_bias)
106
+ if logit_bias is not None:
107
+ payload["logit_bias"] = logit_bias
108
+ for k, v in kwargs.items():
109
+ if k not in payload and k != "logit_bias":
110
+ payload[k] = v
111
+ return payload
112
+
113
+ def _extract_top_logprobs_from_response(self, data: dict[str, Any]) -> list[dict[str, Any]]:
114
+ choices = data.get("choices", [])
115
+ if not choices:
116
+ return []
117
+ logprobs = choices[0].get("logprobs")
118
+ if not logprobs:
119
+ return []
120
+ content_logprobs = logprobs.get("content", [])
121
+ if not content_logprobs:
122
+ return []
123
+ top_logprobs = content_logprobs[0].get("top_logprobs", [])
124
+ return [
125
+ {"token": item.get("token", ""), "logprob": item.get("logprob", -10.0)}
126
+ for item in top_logprobs
127
+ ]
128
+
129
+ def query_top_logprobs(
130
+ self,
131
+ prompt: str,
132
+ top_k: int = 10,
133
+ allowed_tokens: list[str] | None = None,
134
+ **kwargs: Any,
135
+ ) -> list[dict[str, Any]]:
136
+ url = f"{self.base_url}/chat/completions"
137
+ payload = self.build_query_payload(
138
+ prompt, top_k=top_k, allowed_tokens=allowed_tokens, **kwargs
139
+ )
140
+ resp = self.client.post(url, json=payload, headers=self._headers())
141
+ resp.raise_for_status()
142
+ data = resp.json()
143
+ return self._extract_top_logprobs_from_response(data)
144
+
145
+ async def aquery_top_logprobs(
146
+ self,
147
+ prompt: str,
148
+ top_k: int = 10,
149
+ allowed_tokens: list[str] | None = None,
150
+ **kwargs: Any,
151
+ ) -> list[dict[str, Any]]:
152
+ url = f"{self.base_url}/chat/completions"
153
+ payload = self.build_query_payload(
154
+ prompt, top_k=top_k, allowed_tokens=allowed_tokens, **kwargs
155
+ )
156
+ resp = await self.async_client.post(url, json=payload, headers=self._headers())
157
+ resp.raise_for_status()
158
+ data = resp.json()
159
+ return self._extract_top_logprobs_from_response(data)
160
+
161
+ def generate(self, prompt: str, max_tokens: int | None = None, **kwargs: Any) -> tuple[str, int]:
162
+ url = f"{self.base_url}/chat/completions"
163
+ payload: dict[str, Any] = {
164
+ "model": self.model,
165
+ "messages": [{"role": "user", "content": prompt}],
166
+ "temperature": 0.0,
167
+ }
168
+ if max_tokens is not None:
169
+ payload["max_tokens"] = max_tokens
170
+ payload.update(kwargs)
171
+ resp = self.client.post(url, json=payload, headers=self._headers())
172
+ resp.raise_for_status()
173
+ data = resp.json()
174
+ choices = data.get("choices", [])
175
+ content = choices[0].get("message", {}).get("content", "") if choices else ""
176
+ tokens = data.get("usage", {}).get("completion_tokens", 0)
177
+ return strip_reasoning_blocks(content), tokens
178
+
179
+ async def agenerate(self, prompt: str, max_tokens: int | None = None, **kwargs: Any) -> tuple[str, int]:
180
+ url = f"{self.base_url}/chat/completions"
181
+ payload: dict[str, Any] = {
182
+ "model": self.model,
183
+ "messages": [{"role": "user", "content": prompt}],
184
+ "temperature": 0.0,
185
+ }
186
+ if max_tokens is not None:
187
+ payload["max_tokens"] = max_tokens
188
+ payload.update(kwargs)
189
+ resp = await self.async_client.post(url, json=payload, headers=self._headers())
190
+ resp.raise_for_status()
191
+ data = resp.json()
192
+ choices = data.get("choices", [])
193
+ content = choices[0].get("message", {}).get("content", "") if choices else ""
194
+ tokens = data.get("usage", {}).get("completion_tokens", 0)
195
+ return strip_reasoning_blocks(content), tokens