forked from Jason-Vaughan/TangleBrain
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathopenai_compat.py
More file actions
332 lines (280 loc) · 14.4 KB
/
Copy pathopenai_compat.py
File metadata and controls
332 lines (280 loc) · 14.4 KB
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
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
"""OpenAI-compat adapter — the free local tier.
Calls an OpenAI-compatible ``/chat/completions`` endpoint (e.g. Ollama, or any local/self-hosted
gateway) and returns the final text. It calls the endpoint **directly** — no MCP server in between.
Behaviour:
- Returns only ``choices[0].message.content`` — some local reasoning models put chain-of-thought in
a separate ``reasoning_content`` field, which is intentionally dropped.
- Defaults ``max_tokens`` to 2048: reasoning models spend part of their budget on internal reasoning
before emitting the final answer, so a stingy cap can truncate real output.
- Raises on any non-2xx status, transport failure, or unexpected response shape. This layer
does NOT retry or fall back — failures surface to the routing layer, which decides.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Iterator, Mapping
import httpx
from tanglebrain.adapters.base import AdapterError
from tanglebrain.roster import RosterEntry
# Re-exported for backwards-compatible imports; the canonical definition lives in
# ``tanglebrain.adapters.base`` so the CLI adapter and the routing layer share one error type.
__all__ = ["AdapterError", "OpenAICompatAdapter", "resolve_key_ref"]
DEFAULT_TIMEOUT_SECONDS = 300.0
DEFAULT_MAX_TOKENS = 2048
def resolve_key_ref(key_ref: str | None) -> str | None:
"""Resolve a roster ``key_ref`` to a credential string, without embedding secrets.
Supported forms (see the contract's key-ref convention):
- ``file:PATH`` — read the key from a file (``~`` is expanded); the file is the source of
truth, never the config.
- ``env:NAME`` — read the key from environment variable ``NAME``.
- ``none`` (or ``None``) — no credential; the endpoint is open.
Args:
key_ref: The reference string from the roster entry, or ``None``.
Returns:
The resolved key, or ``None`` for an open endpoint.
Raises:
AdapterError: If the form is unrecognized, or the referenced file/env var is missing
or empty.
"""
if key_ref is None or key_ref == "none":
return None
if key_ref.startswith("file:"):
raw_path = key_ref[len("file:"):]
path = Path(raw_path).expanduser()
if not path.exists():
raise AdapterError(f"key_ref file not found: {path}")
key = path.read_text().strip()
if not key:
raise AdapterError(f"key_ref file is empty: {path}")
return key
if key_ref.startswith("env:"):
name = key_ref[len("env:"):]
key = os.environ.get(name)
if not key:
raise AdapterError(f"key_ref env var not set or empty: {name}")
return key
raise AdapterError(
f"unrecognized key_ref {key_ref!r}; expected 'file:PATH', 'env:NAME', or 'none'"
)
class OpenAICompatAdapter:
"""Adapter that runs prompts against an OpenAI-compat chat-completions endpoint.
Implements the uniform :class:`~tanglebrain.adapters.base.Adapter` interface
(``run(prompt, opts) -> text``).
"""
def __init__(
self,
base_url: str,
model: str,
key_ref: str | None = None,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
default_max_tokens: int = DEFAULT_MAX_TOKENS,
) -> None:
"""Configure the adapter.
The credential is resolved lazily (on first :meth:`run`), so constructing an adapter
for an entry whose key file is absent does not fail until it is actually invoked.
Args:
base_url: OpenAI-compat base URL (e.g. ``http://localhost:11434/v1``).
model: Model id/alias to request (e.g. ``gpt-oss-120b``).
key_ref: Credential reference (``file:PATH`` | ``env:NAME`` | ``none``), or ``None``.
timeout: Per-request timeout in seconds.
default_max_tokens: ``max_tokens`` used when a call does not override it.
"""
self.base_url = base_url.rstrip("/")
self.model = model
self.key_ref = key_ref
self.timeout = timeout
self.default_max_tokens = default_max_tokens
@classmethod
def from_entry(cls, entry: RosterEntry, **overrides: object) -> "OpenAICompatAdapter":
"""Build an adapter from an ``openai-compat`` roster entry.
Args:
entry: A roster entry whose ``invoke.kind`` is ``openai-compat``.
**overrides: Optional constructor overrides (``timeout``, ``default_max_tokens``).
Returns:
A configured :class:`OpenAICompatAdapter`.
Raises:
AdapterError: If the entry's invoke kind is not ``openai-compat``.
"""
if entry.invoke.kind != "openai-compat":
raise AdapterError(
f"entry {entry.id!r} has invoke.kind {entry.invoke.kind!r}, "
"not 'openai-compat'"
)
return cls(
base_url=entry.invoke.base_url, # validated non-None by the roster loader
model=entry.invoke.model,
key_ref=entry.invoke.key_ref,
**overrides, # type: ignore[arg-type]
)
def run(self, prompt: str, opts: Mapping[str, object] | None = None) -> str:
"""Send a single-message chat completion and return the final text.
Args:
prompt: The prompt to send as the sole user message.
opts: Optional per-call options. Recognized keys: ``max_tokens`` (int).
Returns:
The model's final ``content`` (``reasoning_content`` is dropped).
Raises:
AdapterError: If ``max_tokens`` < 1, or on non-2xx status, transport failure, or
unexpected response shape.
"""
opts = opts or {}
url, headers, max_tokens = self._prepare_request(opts)
payload = {
"model": self.model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens,
}
try:
with httpx.Client(timeout=self.timeout) as client:
response = client.post(url, headers=headers, json=payload)
response.raise_for_status()
data = response.json()
except httpx.HTTPStatusError as exc:
body = exc.response.text
raise AdapterError(
f"LiteLLM returned {exc.response.status_code} for model {self.model!r}: {body}"
) from exc
except httpx.HTTPError as exc:
raise AdapterError(
f"transport error calling {url} for model {self.model!r}: {exc}"
) from exc
try:
content = data["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as exc:
raise AdapterError(f"unexpected response shape from LiteLLM: {data!r}") from exc
if content is None:
raise AdapterError(
f"LiteLLM returned null content for model {self.model!r} "
f"(often a truncated response — try a larger max_tokens): {data!r}"
)
return content
def _prepare_request(self, opts: Mapping[str, object]) -> tuple[str, dict, int]:
"""Resolve the URL, headers (credential included), and token cap for one call.
Shared by :meth:`run` and :meth:`run_stream` so config/credential failures behave
identically on both paths.
Args:
opts: Per-call options (``max_tokens`` recognized).
Returns:
``(url, headers, max_tokens)``.
Raises:
AdapterError: If ``max_tokens`` < 1 or the credential reference cannot resolve.
"""
max_tokens = int(opts.get("max_tokens", self.default_max_tokens))
if max_tokens < 1:
raise AdapterError(
f"max_tokens must be >= 1, got {max_tokens} "
"(a local reasoning model needs generous headroom)"
)
headers = {"Content-Type": "application/json"}
key = resolve_key_ref(self.key_ref)
if key:
headers["Authorization"] = f"Bearer {key}"
return f"{self.base_url}/chat/completions", headers, max_tokens
def run_stream(self, prompt: str, opts: Mapping[str, object] | None = None) -> Iterator[str]:
"""Stream a single-message chat completion, yielding content deltas as they arrive.
Implements the optional :class:`~tanglebrain.adapters.base.StreamingAdapter` capability:
the same request as :meth:`run` with ``"stream": true``, decoded as SSE pass-through.
Config/credential errors raise **eagerly** (at call time); the HTTP connection opens
lazily on the first iteration, so a caller can pull the first delta before committing
its own response headers (connect-time failures surface from that pull, pre-stream).
Decoding stance (mirrors :meth:`run` where they overlap):
- Yields ``choices[0].delta.content`` fragments; empty/role-only deltas and chunks with
no choices (e.g. a trailing usage chunk) are skipped, never yielded.
- ``reasoning_content`` deltas are dropped, matching ``run``.
- Each ``data:`` line is decoded as one standalone JSON event. Spec-legal multi-line
``data:`` events are NOT reassembled — every real OpenAI-compat backend emits
one-line events, and an exotic one fails loudly (``AdapterError``), never corrupts.
- ``data: [DONE]`` ends the stream; a clean close **without** ``[DONE]`` also ends it
(some local gateways omit the terminator — treat honest EOF as done, not an error).
- A stream that ends cleanly having produced **no content at all** raises
:class:`AdapterError`, mirroring ``run``'s null-content stance — a dead backend that
200s with an empty stream must be a loud error, not a silent empty success.
- An in-stream ``{"error": ...}`` event, a malformed ``data:`` line, a shape-broken
event, a non-2xx status, or a transport failure raises :class:`AdapterError`.
Args:
prompt: The prompt to send as the sole user message.
opts: Optional per-call options. Recognized keys: ``max_tokens`` (int).
Yields:
Non-empty content fragments, in generation order.
Raises:
AdapterError: Eagerly for bad config/credentials; from the first pull for
connect-time failures; mid-iteration for a stream that dies part-way.
"""
opts = opts or {}
url, headers, max_tokens = self._prepare_request(opts)
payload = {
"model": self.model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens,
"stream": True,
}
return self._stream_deltas(url, headers, payload)
def _stream_deltas(self, url: str, headers: dict, payload: dict) -> Iterator[str]:
"""Open the SSE request and yield content deltas (the lazy half of :meth:`run_stream`).
Args:
url: The chat-completions URL.
headers: Request headers (credential already resolved).
payload: The JSON request body (``stream: true`` already set).
Yields:
Non-empty content fragments.
Raises:
AdapterError: On non-2xx status, transport failure, a malformed SSE data line, a
shape-broken event, an in-stream error event, or a clean end with no content.
"""
produced = False
try:
with httpx.Client(timeout=self.timeout) as client:
with client.stream("POST", url, headers=headers, json=payload) as response:
if response.status_code >= 400:
# Read the body before .text — on a stream it is not buffered yet.
body = response.read().decode("utf-8", errors="replace")
raise AdapterError(
f"LiteLLM returned {response.status_code} for model "
f"{self.model!r}: {body}"
)
for line in response.iter_lines():
if not line.startswith("data:"):
continue # SSE comments / event: lines / keep-alive blanks
data = line[len("data:"):].strip()
if data == "[DONE]":
break
try:
event = json.loads(data)
except ValueError as exc:
raise AdapterError(
f"malformed SSE data line from model {self.model!r}: {data!r}"
) from exc
if not isinstance(event, dict):
raise AdapterError(
f"unexpected SSE event shape from model {self.model!r}: {event!r}"
)
if "error" in event:
raise AdapterError(
f"in-stream error from model {self.model!r}: {event['error']!r}"
)
try:
choices = event.get("choices") or []
if not choices:
continue # e.g. a trailing usage-only chunk
delta = choices[0].get("delta") or {}
content = delta.get("content")
except (AttributeError, TypeError, KeyError, IndexError) as exc:
# e.g. {"choices": [null]} — spec-valid JSON, broken shape. Must map
# to AdapterError like every other decode failure (S2's mid-stream
# error framing catches AdapterError, not raw AttributeError).
raise AdapterError(
f"unexpected SSE event shape from model {self.model!r}: {event!r}"
) from exc
if content:
produced = True
yield content
except httpx.HTTPError as exc:
raise AdapterError(
f"transport error streaming {url} for model {self.model!r}: {exc}"
) from exc
if not produced:
raise AdapterError(
f"stream from model {self.model!r} ended with no content "
"(often a truncated response — try a larger max_tokens)"
)