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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Generated from packages/coding.sh/src/integrations/gateway/providers.ts. Do not edit:
|
|
2
|
+
# change the registry, then run `node packages/coding.sh/scripts/sdk-gateways.ts`.
|
|
3
|
+
#
|
|
4
|
+
# One dict per gateway: id, hosts (exact, or a parent domain when it starts with "."), url_env
|
|
5
|
+
# (env vars naming a self-hosted gateway's URL), id_pattern (request id shape), id_headers
|
|
6
|
+
# (response headers carrying the request id `gateway enrich` looks up), mark_headers (other
|
|
7
|
+
# response headers only this gateway sets).
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any, Dict, Tuple
|
|
11
|
+
|
|
12
|
+
GATEWAY_TABLE: Tuple[Dict[str, Any], ...] = (
|
|
13
|
+
{
|
|
14
|
+
"id": "vercel",
|
|
15
|
+
"hosts": ("ai-gateway.vercel.sh",),
|
|
16
|
+
"url_env": (),
|
|
17
|
+
"id_pattern": re.compile("^gen_[0-9A-HJKMNP-TV-Z]{26}$", re.IGNORECASE),
|
|
18
|
+
"id_headers": ("x-generation-id",),
|
|
19
|
+
"mark_headers": (),
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"id": "openrouter",
|
|
23
|
+
"hosts": ("openrouter.ai",),
|
|
24
|
+
"url_env": (),
|
|
25
|
+
"id_pattern": re.compile("^gen-[0-9A-Za-z][0-9A-Za-z-]{11,}$"),
|
|
26
|
+
"id_headers": ("x-generation-id",),
|
|
27
|
+
"mark_headers": (),
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"id": "litellm",
|
|
31
|
+
"hosts": (),
|
|
32
|
+
"url_env": ("LITELLM_PROXY_API_BASE", "LITELLM_PROXY_URL", "LITELLM_BASE_URL"),
|
|
33
|
+
"id_pattern": None,
|
|
34
|
+
"id_headers": ("x-litellm-call-id",),
|
|
35
|
+
"mark_headers": ("x-litellm-response-cost", "x-litellm-model-api-base"),
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"id": "cloudflare",
|
|
39
|
+
"hosts": ("gateway.ai.cloudflare.com",),
|
|
40
|
+
"url_env": (),
|
|
41
|
+
"id_pattern": None,
|
|
42
|
+
"id_headers": ("cf-aig-log-id",),
|
|
43
|
+
"mark_headers": (),
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"id": "portkey",
|
|
47
|
+
"hosts": ("api.portkey.ai",),
|
|
48
|
+
"url_env": ("PORTKEY_GATEWAY_URL",),
|
|
49
|
+
"id_pattern": None,
|
|
50
|
+
"id_headers": ("x-portkey-trace-id",),
|
|
51
|
+
"mark_headers": (),
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"id": "helicone",
|
|
55
|
+
"hosts": (".helicone.ai",),
|
|
56
|
+
"url_env": (),
|
|
57
|
+
"id_pattern": None,
|
|
58
|
+
"id_headers": ("helicone-id",),
|
|
59
|
+
"mark_headers": (),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"id": "requesty",
|
|
63
|
+
"hosts": ("router.requesty.ai", "router.eu.requesty.ai"),
|
|
64
|
+
"url_env": (),
|
|
65
|
+
"id_pattern": re.compile("^rqsty-[A-Za-z0-9-]{8,}$"),
|
|
66
|
+
"id_headers": (),
|
|
67
|
+
"mark_headers": (),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"id": "ramp",
|
|
71
|
+
"hosts": ("api.router.com",),
|
|
72
|
+
"url_env": ("RAMP_ROUTER_BASE_URL",),
|
|
73
|
+
"id_pattern": None,
|
|
74
|
+
"id_headers": (),
|
|
75
|
+
"mark_headers": ("x-router-provider",),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"id": "truefoundry",
|
|
79
|
+
"hosts": (".truefoundry.ai", ".truefoundry.com", ".truefoundry.cloud"),
|
|
80
|
+
"url_env": ("TFY_GATEWAY_URL",),
|
|
81
|
+
"id_pattern": None,
|
|
82
|
+
"id_headers": (),
|
|
83
|
+
"mark_headers": (),
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"id": "kong",
|
|
87
|
+
"hosts": (),
|
|
88
|
+
"url_env": ("KONG_PROXY_URL",),
|
|
89
|
+
"id_pattern": None,
|
|
90
|
+
"id_headers": ("x-kong-request-id",),
|
|
91
|
+
"mark_headers": ("x-kong-llm-model",),
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"id": "bifrost",
|
|
95
|
+
"hosts": (),
|
|
96
|
+
"url_env": ("BIFROST_URL", "BIFROST_BASE_URL"),
|
|
97
|
+
"id_pattern": None,
|
|
98
|
+
"id_headers": (),
|
|
99
|
+
"mark_headers": (),
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"id": "martian",
|
|
103
|
+
"hosts": ("withmartian.com",),
|
|
104
|
+
"url_env": (),
|
|
105
|
+
"id_pattern": None,
|
|
106
|
+
"id_headers": (),
|
|
107
|
+
"mark_headers": (),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"id": "cliproxyapi",
|
|
111
|
+
"hosts": (),
|
|
112
|
+
"url_env": ("CLIPROXYAPI_URL",),
|
|
113
|
+
"id_pattern": None,
|
|
114
|
+
"id_headers": (),
|
|
115
|
+
"mark_headers": (),
|
|
116
|
+
},
|
|
117
|
+
)
|
coding_sh/_llm.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Model-call recording and stream proxies shared by the provider wrappers.
|
|
2
|
+
|
|
3
|
+
The wrappers never change what the app sees: return values are the provider's own objects
|
|
4
|
+
(streams are proxied, every other attribute passes through), exceptions propagate unchanged,
|
|
5
|
+
and recording errors are swallowed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from ._client import dumps
|
|
14
|
+
from ._gateway import detect_gateway
|
|
15
|
+
from ._trace import Scope, clip, emit, full_capture
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def g(obj: Any, key: str, default: Any = None) -> Any:
|
|
19
|
+
"""Read a field from a pydantic model, a plain object or a dict."""
|
|
20
|
+
if obj is None:
|
|
21
|
+
return default
|
|
22
|
+
if isinstance(obj, dict):
|
|
23
|
+
return obj.get(key, default)
|
|
24
|
+
try:
|
|
25
|
+
return getattr(obj, key, default)
|
|
26
|
+
except Exception:
|
|
27
|
+
return default
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def given(v: Any) -> bool:
|
|
31
|
+
"""False for None and the SDKs' NOT_GIVEN / omit sentinels."""
|
|
32
|
+
return v is not None and type(v).__name__ not in ("NotGiven", "Omit")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _num(v: Any) -> Optional[float]:
|
|
36
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
|
37
|
+
return None
|
|
38
|
+
return v if v >= 0 else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def content_text(content: Any) -> Optional[str]:
|
|
42
|
+
"""Text of a message `content`: a string, or a list of text parts/blocks."""
|
|
43
|
+
if isinstance(content, str):
|
|
44
|
+
return content
|
|
45
|
+
if isinstance(content, (list, tuple)):
|
|
46
|
+
parts = []
|
|
47
|
+
for p in content:
|
|
48
|
+
if isinstance(p, str):
|
|
49
|
+
parts.append(p)
|
|
50
|
+
continue
|
|
51
|
+
t = g(p, "type")
|
|
52
|
+
if t in (None, "text", "input_text", "output_text"):
|
|
53
|
+
txt = g(p, "text")
|
|
54
|
+
if isinstance(txt, str) and txt:
|
|
55
|
+
parts.append(txt)
|
|
56
|
+
return "\n".join(parts) if parts else None
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def last_user_text(messages: Any) -> Optional[str]:
|
|
61
|
+
if isinstance(messages, str):
|
|
62
|
+
return messages
|
|
63
|
+
if not isinstance(messages, (list, tuple)):
|
|
64
|
+
return None
|
|
65
|
+
for m in reversed(messages):
|
|
66
|
+
if g(m, "role") == "user":
|
|
67
|
+
t = content_text(g(m, "content"))
|
|
68
|
+
if t:
|
|
69
|
+
return t
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class LLMCall(Scope):
|
|
74
|
+
"""One model API call: llm.call + assistant.message (plus an implicit root when untraced)."""
|
|
75
|
+
|
|
76
|
+
bind_context = False
|
|
77
|
+
|
|
78
|
+
def __init__(self, provider: str, request_model: Any, input_fn: Callable[[], Optional[str]]) -> None:
|
|
79
|
+
super().__init__()
|
|
80
|
+
self.provider = provider
|
|
81
|
+
self.operation = "chat"
|
|
82
|
+
self.request_model = request_model if isinstance(request_model, str) else None
|
|
83
|
+
self._input_fn = input_fn
|
|
84
|
+
self.response_model: Optional[str] = None
|
|
85
|
+
self.text_parts: List[str] = []
|
|
86
|
+
self.stop_reason: Optional[str] = None
|
|
87
|
+
self.usage: Optional[Dict[str, float]] = None
|
|
88
|
+
self.tool_calls: Dict[Any, Dict[str, Any]] = {}
|
|
89
|
+
self.ttft_ms: Optional[float] = None
|
|
90
|
+
self.response_id: Optional[str] = None
|
|
91
|
+
# Which gateway, if any: the client's base URL, and the response's URL and headers.
|
|
92
|
+
self.base_url: Optional[str] = None
|
|
93
|
+
self.request_url: Optional[str] = None
|
|
94
|
+
self.response_headers: Any = None
|
|
95
|
+
|
|
96
|
+
def mark_first_token(self) -> None:
|
|
97
|
+
if self.ttft_ms is None and self._p0:
|
|
98
|
+
self.ttft_ms = round((time.perf_counter() - self._p0) * 1000.0, 3)
|
|
99
|
+
|
|
100
|
+
def add_tool_call(self, key: Any, id: Any = None, name: Any = None, arguments: Any = None, args_delta: Any = None) -> None:
|
|
101
|
+
tc = self.tool_calls.setdefault(key, {})
|
|
102
|
+
if isinstance(id, str) and id:
|
|
103
|
+
tc["id"] = id
|
|
104
|
+
if isinstance(name, str) and name:
|
|
105
|
+
tc["name"] = name
|
|
106
|
+
if arguments is not None:
|
|
107
|
+
tc["arguments"] = arguments
|
|
108
|
+
if isinstance(args_delta, str) and args_delta:
|
|
109
|
+
tc["arguments"] = (tc.get("arguments") if isinstance(tc.get("arguments"), str) else "") + args_delta
|
|
110
|
+
|
|
111
|
+
def set_response_id(self, v: Any) -> None:
|
|
112
|
+
if isinstance(v, str) and v:
|
|
113
|
+
self.response_id = v
|
|
114
|
+
|
|
115
|
+
def _tool_calls_payload(self) -> List[Dict[str, Any]]:
|
|
116
|
+
out = []
|
|
117
|
+
for tc in self.tool_calls.values():
|
|
118
|
+
if not tc.get("name"):
|
|
119
|
+
continue
|
|
120
|
+
o: Dict[str, Any] = {"name": tc["name"]}
|
|
121
|
+
if tc.get("id"):
|
|
122
|
+
o["id"] = tc["id"]
|
|
123
|
+
args = tc.get("arguments")
|
|
124
|
+
if args is not None and full_capture():
|
|
125
|
+
o["arguments"] = clip(args if isinstance(args, str) else dumps(args))
|
|
126
|
+
out.append(o)
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
def _root_name(self) -> str:
|
|
130
|
+
return self.operation + (" " + self.request_model if self.request_model else "")
|
|
131
|
+
|
|
132
|
+
def _root_input(self) -> Optional[str]:
|
|
133
|
+
try:
|
|
134
|
+
return self._input_fn()
|
|
135
|
+
except Exception:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
def _on_end(self, error: Optional[BaseException], duration: float, end_ts: float) -> None:
|
|
139
|
+
if self.node is None:
|
|
140
|
+
return
|
|
141
|
+
from ._trace import error_text
|
|
142
|
+
|
|
143
|
+
gw = None
|
|
144
|
+
try:
|
|
145
|
+
gw = detect_gateway(self.request_url or self.base_url, self.response_headers, self.response_id)
|
|
146
|
+
except Exception:
|
|
147
|
+
gw = None
|
|
148
|
+
p: Dict[str, Any] = {
|
|
149
|
+
"provider": self.provider,
|
|
150
|
+
"requestModel": self.request_model,
|
|
151
|
+
"responseModel": self.response_model,
|
|
152
|
+
"operation": self.operation,
|
|
153
|
+
"ttftMs": self.ttft_ms,
|
|
154
|
+
"stopReason": self.stop_reason,
|
|
155
|
+
"ok": error is None,
|
|
156
|
+
"error": error_text(error) if error else None,
|
|
157
|
+
"responseId": self.response_id,
|
|
158
|
+
# The names the server reads: gateway -> the `gateway:<name>` flag, gatewayGenerationId -> `gateway enrich`.
|
|
159
|
+
"gateway": gw[0] if gw else None,
|
|
160
|
+
"gatewayGenerationId": gw[1] if gw else None,
|
|
161
|
+
}
|
|
162
|
+
calls = self._tool_calls_payload()
|
|
163
|
+
if calls:
|
|
164
|
+
p["toolCalls"] = calls
|
|
165
|
+
out = (self.usage or {}).get("outputTokens")
|
|
166
|
+
if out and duration > 0:
|
|
167
|
+
p["tokensPerSec"] = round(out / (duration / 1000.0), 2)
|
|
168
|
+
model = self.response_model or self.request_model
|
|
169
|
+
nested = not self.is_root
|
|
170
|
+
emit(self.node.event("llm.call", self._t0, p, anchor=nested, child=nested, durationMs=duration, model=model, usage=self.usage or None))
|
|
171
|
+
text = "".join(self.text_parts)
|
|
172
|
+
if text and full_capture():
|
|
173
|
+
emit(self.node.event("assistant.message", end_ts, {"text": clip(text)}, child=nested, model=model))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------- streams
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class SyncStream:
|
|
180
|
+
"""Proxy over a provider's sync stream: same items, same attributes, records on the side."""
|
|
181
|
+
|
|
182
|
+
def __init__(self, stream: Any, call: LLMCall, observe: Callable[[LLMCall, Any], bool]) -> None:
|
|
183
|
+
self._cs_stream = stream
|
|
184
|
+
self._cs_call = call
|
|
185
|
+
self._cs_observe = observe
|
|
186
|
+
self._cs_it: Any = None
|
|
187
|
+
|
|
188
|
+
def _cs_finish(self, error: Optional[BaseException] = None) -> None:
|
|
189
|
+
try:
|
|
190
|
+
self._cs_call.end(error)
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
def __iter__(self) -> "SyncStream":
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
def __next__(self) -> Any:
|
|
198
|
+
if self._cs_it is None:
|
|
199
|
+
self._cs_it = iter(self._cs_stream)
|
|
200
|
+
while True:
|
|
201
|
+
try:
|
|
202
|
+
item = next(self._cs_it)
|
|
203
|
+
except StopIteration:
|
|
204
|
+
self._cs_finish()
|
|
205
|
+
raise
|
|
206
|
+
except Exception as e:
|
|
207
|
+
self._cs_finish(e)
|
|
208
|
+
raise
|
|
209
|
+
except BaseException:
|
|
210
|
+
self._cs_finish()
|
|
211
|
+
raise
|
|
212
|
+
swallow = False
|
|
213
|
+
try:
|
|
214
|
+
swallow = self._cs_observe(self._cs_call, item)
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
if not swallow:
|
|
218
|
+
return item
|
|
219
|
+
|
|
220
|
+
def __enter__(self) -> "SyncStream":
|
|
221
|
+
enter = getattr(self._cs_stream, "__enter__", None)
|
|
222
|
+
if enter is not None:
|
|
223
|
+
enter()
|
|
224
|
+
return self
|
|
225
|
+
|
|
226
|
+
def __exit__(self, *exc: Any) -> Any:
|
|
227
|
+
try:
|
|
228
|
+
ex = getattr(self._cs_stream, "__exit__", None)
|
|
229
|
+
return ex(*exc) if ex is not None else None
|
|
230
|
+
finally:
|
|
231
|
+
self._cs_finish(exc[1] if len(exc) > 1 and isinstance(exc[1], Exception) else None)
|
|
232
|
+
|
|
233
|
+
def close(self) -> Any:
|
|
234
|
+
try:
|
|
235
|
+
return self._cs_stream.close()
|
|
236
|
+
finally:
|
|
237
|
+
self._cs_finish()
|
|
238
|
+
|
|
239
|
+
def __getattr__(self, name: str) -> Any:
|
|
240
|
+
if name.startswith("_cs_"):
|
|
241
|
+
raise AttributeError(name)
|
|
242
|
+
return getattr(self._cs_stream, name)
|
|
243
|
+
|
|
244
|
+
def __del__(self) -> None:
|
|
245
|
+
try:
|
|
246
|
+
self._cs_finish()
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class AsyncStream:
|
|
252
|
+
"""Proxy over a provider's async stream."""
|
|
253
|
+
|
|
254
|
+
def __init__(self, stream: Any, call: LLMCall, observe: Callable[[LLMCall, Any], bool]) -> None:
|
|
255
|
+
self._cs_stream = stream
|
|
256
|
+
self._cs_call = call
|
|
257
|
+
self._cs_observe = observe
|
|
258
|
+
self._cs_it: Any = None
|
|
259
|
+
|
|
260
|
+
def _cs_finish(self, error: Optional[BaseException] = None) -> None:
|
|
261
|
+
try:
|
|
262
|
+
self._cs_call.end(error)
|
|
263
|
+
except Exception:
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
def __aiter__(self) -> "AsyncStream":
|
|
267
|
+
return self
|
|
268
|
+
|
|
269
|
+
async def __anext__(self) -> Any:
|
|
270
|
+
if self._cs_it is None:
|
|
271
|
+
self._cs_it = self._cs_stream.__aiter__()
|
|
272
|
+
while True:
|
|
273
|
+
try:
|
|
274
|
+
item = await self._cs_it.__anext__()
|
|
275
|
+
except StopAsyncIteration:
|
|
276
|
+
self._cs_finish()
|
|
277
|
+
raise
|
|
278
|
+
except Exception as e:
|
|
279
|
+
self._cs_finish(e)
|
|
280
|
+
raise
|
|
281
|
+
except BaseException:
|
|
282
|
+
self._cs_finish()
|
|
283
|
+
raise
|
|
284
|
+
swallow = False
|
|
285
|
+
try:
|
|
286
|
+
swallow = self._cs_observe(self._cs_call, item)
|
|
287
|
+
except Exception:
|
|
288
|
+
pass
|
|
289
|
+
if not swallow:
|
|
290
|
+
return item
|
|
291
|
+
|
|
292
|
+
async def __aenter__(self) -> "AsyncStream":
|
|
293
|
+
enter = getattr(self._cs_stream, "__aenter__", None)
|
|
294
|
+
if enter is not None:
|
|
295
|
+
await enter()
|
|
296
|
+
return self
|
|
297
|
+
|
|
298
|
+
async def __aexit__(self, *exc: Any) -> Any:
|
|
299
|
+
try:
|
|
300
|
+
ex = getattr(self._cs_stream, "__aexit__", None)
|
|
301
|
+
return (await ex(*exc)) if ex is not None else None
|
|
302
|
+
finally:
|
|
303
|
+
self._cs_finish(exc[1] if len(exc) > 1 and isinstance(exc[1], Exception) else None)
|
|
304
|
+
|
|
305
|
+
async def close(self) -> Any:
|
|
306
|
+
try:
|
|
307
|
+
r = self._cs_stream.close()
|
|
308
|
+
if hasattr(r, "__await__"):
|
|
309
|
+
r = await r
|
|
310
|
+
return r
|
|
311
|
+
finally:
|
|
312
|
+
self._cs_finish()
|
|
313
|
+
|
|
314
|
+
def __getattr__(self, name: str) -> Any:
|
|
315
|
+
if name.startswith("_cs_"):
|
|
316
|
+
raise AttributeError(name)
|
|
317
|
+
return getattr(self._cs_stream, name)
|
|
318
|
+
|
|
319
|
+
def __del__(self) -> None:
|
|
320
|
+
try:
|
|
321
|
+
self._cs_finish()
|
|
322
|
+
except Exception:
|
|
323
|
+
pass
|