coding-sh 0.1.3__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.
- coding_sh/__init__.py +33 -0
- coding_sh/_client.py +418 -0
- coding_sh/_gateway.py +156 -0
- coding_sh/_gateway_table.py +117 -0
- coding_sh/_llm.py +323 -0
- coding_sh/_providers.py +549 -0
- coding_sh/_trace.py +641 -0
- coding_sh/otel.py +144 -0
- coding_sh/py.typed +0 -0
- coding_sh-0.1.3.dist-info/METADATA +138 -0
- coding_sh-0.1.3.dist-info/RECORD +14 -0
- coding_sh-0.1.3.dist-info/WHEEL +5 -0
- coding_sh-0.1.3.dist-info/licenses/LICENSE +21 -0
- coding_sh-0.1.3.dist-info/top_level.txt +1 -0
coding_sh/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""coding.sh Python SDK: send LLM calls, traces and tool spans to a coding.sh server.
|
|
2
|
+
|
|
3
|
+
import coding_sh
|
|
4
|
+
from openai import OpenAI
|
|
5
|
+
|
|
6
|
+
client = coding_sh.observe(OpenAI()) # also AsyncOpenAI, Anthropic, AsyncAnthropic
|
|
7
|
+
|
|
8
|
+
with coding_sh.trace("support-reply", session_id=conversation_id, input=question):
|
|
9
|
+
with coding_sh.span("tool", "lookup_order", input={"id": 42}) as s:
|
|
10
|
+
s.set_output(lookup(42))
|
|
11
|
+
client.chat.completions.create(model="gpt-4o-mini", messages=[...])
|
|
12
|
+
|
|
13
|
+
Set CODING_SH_URL and CODING_SH_INGEST_KEY. Without a URL the SDK does nothing. It never
|
|
14
|
+
changes return values, errors or stream semantics, and never raises into the app.
|
|
15
|
+
Stdlib only; one daemon thread sends batches, and an atexit flush is bounded to 2 s.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from ._client import __version__
|
|
19
|
+
from ._providers import observe
|
|
20
|
+
from ._trace import current_session_id, flush, init, shutdown, span, stats, trace, with_session
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"__version__",
|
|
24
|
+
"init",
|
|
25
|
+
"observe",
|
|
26
|
+
"trace",
|
|
27
|
+
"span",
|
|
28
|
+
"with_session",
|
|
29
|
+
"current_session_id",
|
|
30
|
+
"flush",
|
|
31
|
+
"shutdown",
|
|
32
|
+
"stats",
|
|
33
|
+
]
|
coding_sh/_client.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""Config and transport: a bounded queue drained by one daemon sender thread.
|
|
2
|
+
|
|
3
|
+
Transport contract (docs/PRODUCTION.md section 4, shared with the TS SDK):
|
|
4
|
+
|
|
5
|
+
- batches of <= max_batch events and <= 4 MiB go to POST <url>/v1/ingest, gzip-compressed;
|
|
6
|
+
- one request in flight at a time, from a daemon thread;
|
|
7
|
+
- retries on 429, 5xx and network errors, honoring Retry-After, otherwise backing off
|
|
8
|
+
1, 2, 4 ... s up to 30 s, with at most 5 attempts;
|
|
9
|
+
- 400/401/403 (and any other 4xx) drop the batch and warn once per status;
|
|
10
|
+
- a full queue drops new events, counts them, and warns at most once a minute;
|
|
11
|
+
- an atexit flush is bounded to 2 s;
|
|
12
|
+
- event ids are fixed when the event is created, so a retried batch never duplicates.
|
|
13
|
+
|
|
14
|
+
Nothing here ever raises into the app, and nothing logs the key or a request body.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import atexit
|
|
20
|
+
import collections
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
import urllib.error
|
|
27
|
+
import urllib.request
|
|
28
|
+
from typing import Any, Deque, Dict, List, Optional
|
|
29
|
+
|
|
30
|
+
try: # gzip needs zlib, which a few minimal builds lack.
|
|
31
|
+
import gzip as _gzip
|
|
32
|
+
except Exception: # pragma: no cover
|
|
33
|
+
_gzip = None # type: ignore[assignment]
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.3"
|
|
36
|
+
|
|
37
|
+
log = logging.getLogger("coding_sh")
|
|
38
|
+
|
|
39
|
+
MAX_BATCH_BYTES = 4 * 1024 * 1024
|
|
40
|
+
MAX_ATTEMPTS = 5
|
|
41
|
+
MAX_BACKOFF_S = 30.0
|
|
42
|
+
ATEXIT_TIMEOUT_S = 2.0
|
|
43
|
+
REQUEST_TIMEOUT_S = 10.0
|
|
44
|
+
QUEUE_WARN_INTERVAL_S = 60.0
|
|
45
|
+
|
|
46
|
+
_TRUE = {"1", "true", "yes", "on"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _env(name: str) -> Optional[str]:
|
|
50
|
+
v = os.environ.get(name)
|
|
51
|
+
return v if v not in (None, "") else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _env_bool(name: str) -> Optional[bool]:
|
|
55
|
+
v = _env(name)
|
|
56
|
+
return None if v is None else v.strip().lower() in _TRUE
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def ingest_url(url: str) -> str:
|
|
60
|
+
"""Base URL (https://obs.example.com) -> https://obs.example.com/v1/ingest."""
|
|
61
|
+
u = url.strip().rstrip("/")
|
|
62
|
+
return u if u.endswith("/v1/ingest") else u + "/v1/ingest"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Config:
|
|
66
|
+
"""Resolved settings. Arguments win over env vars, env vars over defaults."""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
url: Optional[str] = None,
|
|
71
|
+
key: Optional[str] = None,
|
|
72
|
+
service: Optional[str] = None,
|
|
73
|
+
capture: Optional[str] = None,
|
|
74
|
+
flush_interval_ms: Optional[float] = None,
|
|
75
|
+
max_batch: Optional[int] = None,
|
|
76
|
+
max_queue: Optional[int] = None,
|
|
77
|
+
disabled: Optional[bool] = None,
|
|
78
|
+
debug: Optional[bool] = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
self.url = url or _env("CODING_SH_URL")
|
|
81
|
+
self.key = key or _env("CODING_SH_INGEST_KEY")
|
|
82
|
+
self.service = service or _env("CODING_SH_SERVICE") or _env("OTEL_SERVICE_NAME")
|
|
83
|
+
mode = (capture or _env("CODING_SH_CAPTURE_MODE") or "full").strip().lower()
|
|
84
|
+
self.capture = "metadata" if mode == "metadata" else "full"
|
|
85
|
+
self.flush_interval_s = max(0.0, float(1000 if flush_interval_ms is None else flush_interval_ms)) / 1000.0
|
|
86
|
+
self.max_batch = max(1, min(500, int(500 if max_batch is None else max_batch)))
|
|
87
|
+
self.max_queue = max(1, int(10000 if max_queue is None else max_queue))
|
|
88
|
+
env_disabled = _env_bool("CODING_SH_DISABLED")
|
|
89
|
+
self.disabled = bool(disabled if disabled is not None else env_disabled) or not self.url
|
|
90
|
+
self.debug = bool(debug if debug is not None else _env_bool("CODING_SH_DEBUG"))
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def enabled(self) -> bool:
|
|
94
|
+
return not self.disabled
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def full_capture(self) -> bool:
|
|
98
|
+
return self.capture == "full"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _json_default(o: Any) -> Any:
|
|
102
|
+
for attr in ("model_dump", "to_dict", "dict"):
|
|
103
|
+
f = getattr(o, attr, None)
|
|
104
|
+
if callable(f):
|
|
105
|
+
try:
|
|
106
|
+
return f()
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
if isinstance(o, (set, frozenset, tuple)):
|
|
110
|
+
return list(o)
|
|
111
|
+
if isinstance(o, bytes):
|
|
112
|
+
return "<%d bytes>" % len(o)
|
|
113
|
+
return repr(o)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def dumps(o: Any) -> str:
|
|
117
|
+
return json.dumps(o, default=_json_default, ensure_ascii=False, separators=(",", ":"))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class _Result:
|
|
121
|
+
__slots__ = ("status", "retry_after", "body")
|
|
122
|
+
|
|
123
|
+
def __init__(self, status: int, retry_after: Optional[float] = None, body: bytes = b"") -> None:
|
|
124
|
+
self.status = status # 0 = network error
|
|
125
|
+
self.retry_after = retry_after
|
|
126
|
+
self.body = body
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _parse_retry_after(v: Optional[str]) -> Optional[float]:
|
|
130
|
+
if not v:
|
|
131
|
+
return None
|
|
132
|
+
try:
|
|
133
|
+
return max(0.0, min(MAX_BACKOFF_S, float(v.strip())))
|
|
134
|
+
except ValueError:
|
|
135
|
+
return None # HTTP-date form: fall back to backoff
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class Transport:
|
|
139
|
+
"""Thread-safe event queue plus a lazily started daemon sender."""
|
|
140
|
+
|
|
141
|
+
# Patched by tests to keep retries fast.
|
|
142
|
+
backoff_base_s = 1.0
|
|
143
|
+
|
|
144
|
+
def __init__(self, cfg: Config) -> None:
|
|
145
|
+
self.cfg = cfg
|
|
146
|
+
self._endpoint = ingest_url(cfg.url) if cfg.url else ""
|
|
147
|
+
self._envelope_head = (
|
|
148
|
+
'{"v":0,' + ('"service":' + dumps(cfg.service) + "," if cfg.service else "") + '"events":['
|
|
149
|
+
).encode("utf-8")
|
|
150
|
+
self._reset_state()
|
|
151
|
+
self._warned: Dict[str, bool] = {}
|
|
152
|
+
self._last_queue_warn = 0.0
|
|
153
|
+
if cfg.enabled:
|
|
154
|
+
atexit.register(self._atexit)
|
|
155
|
+
if hasattr(os, "register_at_fork"):
|
|
156
|
+
os.register_at_fork(after_in_child=self._after_fork)
|
|
157
|
+
|
|
158
|
+
# ------------------------------------------------------------------ state
|
|
159
|
+
|
|
160
|
+
def _reset_state(self) -> None:
|
|
161
|
+
# Reentrant: a stream proxy finalized by the GC may enqueue while this thread holds it.
|
|
162
|
+
self._lock = threading.RLock()
|
|
163
|
+
self._cv = threading.Condition(self._lock)
|
|
164
|
+
self._q: Deque[bytes] = collections.deque() # serialized events
|
|
165
|
+
self._inflight = 0
|
|
166
|
+
self._flush_waiters = 0
|
|
167
|
+
self._thread: Optional[threading.Thread] = None
|
|
168
|
+
self._closed = False
|
|
169
|
+
self._halt = threading.Event() # set once the process is going away for good
|
|
170
|
+
self._sent = 0
|
|
171
|
+
self._dropped = 0
|
|
172
|
+
self._failed = 0
|
|
173
|
+
|
|
174
|
+
def _after_fork(self) -> None:
|
|
175
|
+
# The child gets a copy of the parent's queue, which the parent still sends.
|
|
176
|
+
# Start clean so nothing is sent twice and no dead thread or held lock is inherited.
|
|
177
|
+
self._reset_state()
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------------------ public
|
|
180
|
+
|
|
181
|
+
def enqueue(self, event: Dict[str, Any]) -> None:
|
|
182
|
+
if not self.cfg.enabled:
|
|
183
|
+
return
|
|
184
|
+
try:
|
|
185
|
+
data = dumps(event).encode("utf-8")
|
|
186
|
+
except Exception as e: # pragma: no cover - dumps has a repr fallback
|
|
187
|
+
self._debug("event not serializable: %s", type(e).__name__)
|
|
188
|
+
return
|
|
189
|
+
warn = False
|
|
190
|
+
with self._cv:
|
|
191
|
+
if self._closed:
|
|
192
|
+
return
|
|
193
|
+
if len(data) + len(self._envelope_head) + 2 > MAX_BATCH_BYTES or len(self._q) >= self.cfg.max_queue:
|
|
194
|
+
self._dropped += 1
|
|
195
|
+
now = time.monotonic()
|
|
196
|
+
if now - self._last_queue_warn >= QUEUE_WARN_INTERVAL_S:
|
|
197
|
+
self._last_queue_warn = now
|
|
198
|
+
warn = True
|
|
199
|
+
else:
|
|
200
|
+
self._q.append(data)
|
|
201
|
+
if len(self._q) >= self.cfg.max_batch:
|
|
202
|
+
self._cv.notify_all()
|
|
203
|
+
self._ensure_thread()
|
|
204
|
+
if warn:
|
|
205
|
+
log.warning("coding.sh: event queue full or event too large; dropping events (dropped=%d)", self._dropped)
|
|
206
|
+
|
|
207
|
+
def flush(self, timeout: Optional[float] = None) -> bool:
|
|
208
|
+
"""Block until everything queued so far is sent (or given up). True when drained."""
|
|
209
|
+
if not self.cfg.enabled:
|
|
210
|
+
return True
|
|
211
|
+
deadline = None if timeout is None else time.monotonic() + max(0.0, timeout)
|
|
212
|
+
with self._cv:
|
|
213
|
+
if not self._q and not self._inflight:
|
|
214
|
+
return True
|
|
215
|
+
alive = self._thread is not None and self._thread.is_alive()
|
|
216
|
+
if not alive:
|
|
217
|
+
alive = self._ensure_thread()
|
|
218
|
+
if not alive:
|
|
219
|
+
# No thread can be started (interpreter shutdown): send in this thread.
|
|
220
|
+
self._drain_sync(deadline)
|
|
221
|
+
with self._cv:
|
|
222
|
+
return not self._q and not self._inflight
|
|
223
|
+
with self._cv:
|
|
224
|
+
self._flush_waiters += 1
|
|
225
|
+
self._cv.notify_all()
|
|
226
|
+
try:
|
|
227
|
+
while self._q or self._inflight:
|
|
228
|
+
remaining = None if deadline is None else deadline - time.monotonic()
|
|
229
|
+
if remaining is not None and remaining <= 0:
|
|
230
|
+
return False
|
|
231
|
+
self._cv.wait(remaining if remaining is not None else 0.5)
|
|
232
|
+
return True
|
|
233
|
+
finally:
|
|
234
|
+
self._flush_waiters -= 1
|
|
235
|
+
|
|
236
|
+
def shutdown(self, timeout: Optional[float] = ATEXIT_TIMEOUT_S) -> bool:
|
|
237
|
+
ok = self.flush(timeout)
|
|
238
|
+
with self._cv:
|
|
239
|
+
self._closed = True
|
|
240
|
+
self._cv.notify_all()
|
|
241
|
+
return ok
|
|
242
|
+
|
|
243
|
+
def stats(self) -> Dict[str, int]:
|
|
244
|
+
with self._lock:
|
|
245
|
+
return {
|
|
246
|
+
"queued": len(self._q) + self._inflight,
|
|
247
|
+
"sent": self._sent,
|
|
248
|
+
"dropped": self._dropped,
|
|
249
|
+
"failed": self._failed,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
# ------------------------------------------------------------------ sender
|
|
253
|
+
|
|
254
|
+
def _ensure_thread(self) -> bool:
|
|
255
|
+
"""Start the sender if needed. Caller holds the lock."""
|
|
256
|
+
if self._thread is not None and self._thread.is_alive():
|
|
257
|
+
return True
|
|
258
|
+
try:
|
|
259
|
+
t = threading.Thread(target=self._run, name="coding-sh-sender", daemon=True)
|
|
260
|
+
t.start()
|
|
261
|
+
except RuntimeError: # "can't create new thread at interpreter shutdown"
|
|
262
|
+
self._thread = None
|
|
263
|
+
return False
|
|
264
|
+
self._thread = t
|
|
265
|
+
return True
|
|
266
|
+
|
|
267
|
+
def _take_batch(self) -> List[bytes]:
|
|
268
|
+
"""Pop up to max_batch events / 4 MiB. Caller holds the lock."""
|
|
269
|
+
batch: List[bytes] = []
|
|
270
|
+
size = len(self._envelope_head) + 2
|
|
271
|
+
while self._q and len(batch) < self.cfg.max_batch:
|
|
272
|
+
data = self._q[0]
|
|
273
|
+
if batch and size + len(data) + 1 > MAX_BATCH_BYTES:
|
|
274
|
+
break
|
|
275
|
+
self._q.popleft()
|
|
276
|
+
batch.append(data)
|
|
277
|
+
size += len(data) + 1
|
|
278
|
+
self._inflight = len(batch)
|
|
279
|
+
return batch
|
|
280
|
+
|
|
281
|
+
def _run(self) -> None:
|
|
282
|
+
while True:
|
|
283
|
+
with self._cv:
|
|
284
|
+
while not self._q and not self._closed:
|
|
285
|
+
self._cv.wait()
|
|
286
|
+
if not self._q:
|
|
287
|
+
return
|
|
288
|
+
# Give the batch time to fill unless someone is waiting on a flush.
|
|
289
|
+
deadline = time.monotonic() + self.cfg.flush_interval_s
|
|
290
|
+
while len(self._q) < self.cfg.max_batch and not self._flush_waiters and not self._closed:
|
|
291
|
+
remaining = deadline - time.monotonic()
|
|
292
|
+
if remaining <= 0:
|
|
293
|
+
break
|
|
294
|
+
self._cv.wait(remaining)
|
|
295
|
+
batch = self._take_batch()
|
|
296
|
+
try:
|
|
297
|
+
self._deliver(batch)
|
|
298
|
+
except Exception as e: # pragma: no cover - _deliver handles its own errors
|
|
299
|
+
self._debug("sender error: %s", type(e).__name__)
|
|
300
|
+
with self._cv:
|
|
301
|
+
self._failed += len(batch)
|
|
302
|
+
finally:
|
|
303
|
+
with self._cv:
|
|
304
|
+
self._inflight = 0
|
|
305
|
+
self._cv.notify_all()
|
|
306
|
+
|
|
307
|
+
def _drain_sync(self, deadline: Optional[float]) -> None:
|
|
308
|
+
while True:
|
|
309
|
+
with self._cv:
|
|
310
|
+
if not self._q:
|
|
311
|
+
return
|
|
312
|
+
batch = self._take_batch()
|
|
313
|
+
try:
|
|
314
|
+
self._deliver(batch, deadline)
|
|
315
|
+
finally:
|
|
316
|
+
with self._cv:
|
|
317
|
+
self._inflight = 0
|
|
318
|
+
self._cv.notify_all()
|
|
319
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
def _deliver(self, batch: List[bytes], deadline: Optional[float] = None) -> None:
|
|
323
|
+
if not batch:
|
|
324
|
+
return
|
|
325
|
+
body = self._envelope_head + b",".join(batch) + b"]}"
|
|
326
|
+
n = len(batch)
|
|
327
|
+
for attempt in range(1, MAX_ATTEMPTS + 1):
|
|
328
|
+
res = self._post(body)
|
|
329
|
+
if 200 <= res.status < 300:
|
|
330
|
+
self._on_accepted(n, res.body)
|
|
331
|
+
return
|
|
332
|
+
retryable = res.status == 0 or res.status == 429 or res.status >= 500
|
|
333
|
+
if not retryable:
|
|
334
|
+
self._warn_once(
|
|
335
|
+
"status:%d" % res.status,
|
|
336
|
+
"coding.sh: ingest returned HTTP %d; dropping %d events%s"
|
|
337
|
+
% (res.status, n, " (check CODING_SH_INGEST_KEY)" if res.status in (401, 403) else ""),
|
|
338
|
+
)
|
|
339
|
+
break
|
|
340
|
+
if attempt == MAX_ATTEMPTS:
|
|
341
|
+
self._warn_once("retries", "coding.sh: ingest unavailable after %d attempts; dropping events" % MAX_ATTEMPTS)
|
|
342
|
+
break
|
|
343
|
+
delay = res.retry_after
|
|
344
|
+
if delay is None:
|
|
345
|
+
delay = min(MAX_BACKOFF_S, self.backoff_base_s * (2 ** (attempt - 1)))
|
|
346
|
+
if deadline is not None and time.monotonic() + delay > deadline:
|
|
347
|
+
break
|
|
348
|
+
self._debug("ingest %s, retrying in %.1fs", res.status or "network error", delay)
|
|
349
|
+
if self._halt.wait(delay):
|
|
350
|
+
break
|
|
351
|
+
with self._cv:
|
|
352
|
+
self._failed += n
|
|
353
|
+
|
|
354
|
+
def _on_accepted(self, n: int, body: bytes) -> None:
|
|
355
|
+
rejected = 0
|
|
356
|
+
try:
|
|
357
|
+
parsed = json.loads(body.decode("utf-8")) if body else {}
|
|
358
|
+
rej = parsed.get("rejected") if isinstance(parsed, dict) else None
|
|
359
|
+
if isinstance(rej, list) and rej:
|
|
360
|
+
rejected = len(rej)
|
|
361
|
+
first = rej[0] if isinstance(rej[0], dict) else {}
|
|
362
|
+
self._warn_once("rejected", "coding.sh: server rejected %d event(s): %s" % (rejected, str(first.get("error", ""))[:200]))
|
|
363
|
+
except Exception:
|
|
364
|
+
pass
|
|
365
|
+
with self._cv:
|
|
366
|
+
self._sent += n - rejected
|
|
367
|
+
self._failed += rejected
|
|
368
|
+
|
|
369
|
+
def _post(self, body: bytes) -> _Result:
|
|
370
|
+
headers = {"Content-Type": "application/json", "User-Agent": "coding-sh-python/" + __version__}
|
|
371
|
+
data = body
|
|
372
|
+
if _gzip is not None:
|
|
373
|
+
try:
|
|
374
|
+
data = _gzip.compress(body, compresslevel=6)
|
|
375
|
+
headers["Content-Encoding"] = "gzip"
|
|
376
|
+
except Exception:
|
|
377
|
+
data = body
|
|
378
|
+
if self.cfg.key:
|
|
379
|
+
headers["Authorization"] = "Bearer " + self.cfg.key
|
|
380
|
+
req = urllib.request.Request(self._endpoint, data=data, headers=headers, method="POST")
|
|
381
|
+
try:
|
|
382
|
+
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_S) as resp:
|
|
383
|
+
return _Result(resp.status, None, resp.read(1 << 20))
|
|
384
|
+
except urllib.error.HTTPError as e:
|
|
385
|
+
try:
|
|
386
|
+
e.read()
|
|
387
|
+
except Exception:
|
|
388
|
+
pass
|
|
389
|
+
ra = _parse_retry_after(e.headers.get("Retry-After") if e.headers else None)
|
|
390
|
+
try:
|
|
391
|
+
e.close()
|
|
392
|
+
except Exception:
|
|
393
|
+
pass
|
|
394
|
+
return _Result(e.code, ra)
|
|
395
|
+
except Exception as e: # URLError, timeouts, connection resets
|
|
396
|
+
self._debug("ingest network error: %s", type(e).__name__)
|
|
397
|
+
return _Result(0)
|
|
398
|
+
|
|
399
|
+
# ------------------------------------------------------------------ misc
|
|
400
|
+
|
|
401
|
+
def _atexit(self) -> None:
|
|
402
|
+
try:
|
|
403
|
+
self.flush(ATEXIT_TIMEOUT_S)
|
|
404
|
+
except Exception:
|
|
405
|
+
pass
|
|
406
|
+
finally:
|
|
407
|
+
self._halt.set()
|
|
408
|
+
|
|
409
|
+
def _warn_once(self, key: str, msg: str) -> None:
|
|
410
|
+
if self._warned.get(key):
|
|
411
|
+
self._debug("%s", msg)
|
|
412
|
+
return
|
|
413
|
+
self._warned[key] = True
|
|
414
|
+
log.warning("%s", msg)
|
|
415
|
+
|
|
416
|
+
def _debug(self, fmt: str, *args: Any) -> None:
|
|
417
|
+
if self.cfg.debug:
|
|
418
|
+
log.warning("coding.sh debug: " + fmt, *args)
|
coding_sh/_gateway.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Which LLM gateway a model call went through, and the request id its lookup API takes.
|
|
2
|
+
|
|
3
|
+
The table (_gateway_table.py) is generated from the server's gateway registry, so the SDK spots
|
|
4
|
+
the same gateways the server does. Signals, strongest first:
|
|
5
|
+
|
|
6
|
+
1. the base URL (or request URL): a gateway host, or a self-hosted gateway whose URL env var
|
|
7
|
+
(LITELLM_PROXY_API_BASE, KONG_PROXY_URL, ...) names the same host;
|
|
8
|
+
2. a response header only one gateway sets: its request id header (cf-aig-log-id, helicone-id,
|
|
9
|
+
x-litellm-call-id, x-portkey-trace-id, ...), or a marker header (x-router-provider, ...);
|
|
10
|
+
3. a response id with a gateway's shape (Vercel `gen_...`, OpenRouter `gen-...`, Requesty `rqsty-...`).
|
|
11
|
+
|
|
12
|
+
Response headers come from an httpx response hook added to the observed client's HTTP client.
|
|
13
|
+
The hook only reads headers, and only for a call this SDK is recording (a context variable).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import contextlib
|
|
19
|
+
import contextvars
|
|
20
|
+
import inspect
|
|
21
|
+
import os
|
|
22
|
+
import urllib.parse
|
|
23
|
+
from typing import Any, Dict, Iterator, Mapping, Optional, Tuple
|
|
24
|
+
|
|
25
|
+
from ._gateway_table import GATEWAY_TABLE
|
|
26
|
+
|
|
27
|
+
_HOOKED = "__coding_sh_gateway_hook__"
|
|
28
|
+
_current: contextvars.ContextVar[Any] = contextvars.ContextVar("coding_sh_llm_call", default=None)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _host(url: Any) -> Optional[str]:
|
|
32
|
+
if not url:
|
|
33
|
+
return None
|
|
34
|
+
try:
|
|
35
|
+
host = urllib.parse.urlsplit(str(url)).hostname
|
|
36
|
+
except Exception:
|
|
37
|
+
return None
|
|
38
|
+
return host.lower() if host else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _host_matches(host: str, pattern: str) -> bool:
|
|
42
|
+
return host.endswith(pattern) if pattern.startswith(".") else host == pattern or host.endswith("." + pattern)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def gateway_for_url(url: Any, env: Optional[Mapping[str, str]] = None) -> Optional[str]:
|
|
46
|
+
"""The gateway a base URL points at: by hostname, or a self-hosted gateway's URL env var on the same host."""
|
|
47
|
+
host = _host(url)
|
|
48
|
+
if not host:
|
|
49
|
+
return None
|
|
50
|
+
env = os.environ if env is None else env
|
|
51
|
+
for g in GATEWAY_TABLE:
|
|
52
|
+
if any(_host_matches(host, h) for h in g["hosts"]):
|
|
53
|
+
return g["id"]
|
|
54
|
+
if any(_host(env.get(k)) == host for k in g["url_env"]):
|
|
55
|
+
return g["id"]
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _header(headers: Any, name: str) -> Optional[str]:
|
|
60
|
+
if headers is None:
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
v = headers.get(name)
|
|
64
|
+
except Exception:
|
|
65
|
+
return None
|
|
66
|
+
return v.strip() if isinstance(v, str) and v.strip() else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _shaped(g: Dict[str, Any], v: Optional[str]) -> bool:
|
|
70
|
+
return bool(v) and (g["id_pattern"] is None or g["id_pattern"].search(v) is not None)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _request_id(g: Dict[str, Any], headers: Any, response_id: Optional[str]) -> Optional[str]:
|
|
74
|
+
for name in g["id_headers"]:
|
|
75
|
+
v = _header(headers, name)
|
|
76
|
+
if _shaped(g, v):
|
|
77
|
+
return v
|
|
78
|
+
if response_id and g["id_pattern"] is not None and g["id_pattern"].search(response_id):
|
|
79
|
+
return response_id
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def detect_gateway(
|
|
84
|
+
url: Any = None, headers: Any = None, response_id: Optional[str] = None, env: Optional[Mapping[str, str]] = None
|
|
85
|
+
) -> Optional[Tuple[str, Optional[str]]]:
|
|
86
|
+
"""(gateway, request id) for a model call, from its URL, response headers and response id."""
|
|
87
|
+
by_url = gateway_for_url(url, env)
|
|
88
|
+
if by_url:
|
|
89
|
+
g = next(r for r in GATEWAY_TABLE if r["id"] == by_url)
|
|
90
|
+
return by_url, _request_id(g, headers, response_id)
|
|
91
|
+
for g in GATEWAY_TABLE:
|
|
92
|
+
for name in g["id_headers"]:
|
|
93
|
+
v = _header(headers, name)
|
|
94
|
+
if _shaped(g, v):
|
|
95
|
+
return g["id"], v
|
|
96
|
+
for g in GATEWAY_TABLE:
|
|
97
|
+
if any(_header(headers, name) is not None for name in g["mark_headers"]):
|
|
98
|
+
return g["id"], _request_id(g, headers, response_id)
|
|
99
|
+
for g in GATEWAY_TABLE:
|
|
100
|
+
if response_id and g["id_pattern"] is not None and g["id_pattern"].search(response_id):
|
|
101
|
+
return g["id"], response_id
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------- response headers
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@contextlib.contextmanager
|
|
109
|
+
def capturing(call: Any) -> Iterator[None]:
|
|
110
|
+
"""Response headers seen inside this block belong to `call`."""
|
|
111
|
+
token = _current.set(call)
|
|
112
|
+
try:
|
|
113
|
+
yield
|
|
114
|
+
finally:
|
|
115
|
+
try:
|
|
116
|
+
_current.reset(token)
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _take(response: Any) -> None:
|
|
122
|
+
call = _current.get()
|
|
123
|
+
if call is None:
|
|
124
|
+
return
|
|
125
|
+
try:
|
|
126
|
+
call.response_headers = response.headers
|
|
127
|
+
call.request_url = str(response.request.url)
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _sync_hook(response: Any) -> None:
|
|
133
|
+
_take(response)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def _async_hook(response: Any) -> None:
|
|
137
|
+
_take(response)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
setattr(_sync_hook, _HOOKED, True)
|
|
141
|
+
setattr(_async_hook, _HOOKED, True)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def hook_responses(client: Any) -> None:
|
|
145
|
+
"""Add the header hook to an openai / anthropic client's httpx client (once)."""
|
|
146
|
+
try:
|
|
147
|
+
http = getattr(client, "_client", None)
|
|
148
|
+
hooks = getattr(http, "event_hooks", None)
|
|
149
|
+
if not isinstance(hooks, dict) or not isinstance(hooks.get("response"), list):
|
|
150
|
+
return
|
|
151
|
+
if any(getattr(h, _HOOKED, False) for h in hooks["response"]):
|
|
152
|
+
return
|
|
153
|
+
hook = _async_hook if inspect.iscoroutinefunction(getattr(http, "send", None)) else _sync_hook
|
|
154
|
+
http.event_hooks = {**hooks, "response": [*hooks["response"], hook]}
|
|
155
|
+
except Exception:
|
|
156
|
+
pass
|