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/otel.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""OpenTelemetry gen_ai setup for coding.sh, for apps that already emit OTel spans.
|
|
2
|
+
|
|
3
|
+
coding.sh serve takes OTLP/HTTP at <url>/v1/traces, so plain OTel works with four env vars
|
|
4
|
+
(docs/PRODUCTION.md section 4.3). This module only saves the boilerplate:
|
|
5
|
+
|
|
6
|
+
from coding_sh.otel import configure
|
|
7
|
+
configure(service="checkout-api") # TracerProvider + OTLP/HTTP exporter + Bearer key
|
|
8
|
+
|
|
9
|
+
# or, for a subprocess / `opentelemetry-instrument`:
|
|
10
|
+
env = {**os.environ, **coding_sh.otel.otel_env()}
|
|
11
|
+
|
|
12
|
+
configure() resolves the endpoint like this: explicit `url`, then the standard
|
|
13
|
+
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT / OTEL_EXPORTER_OTLP_ENDPOINT (left to the exporter, with
|
|
14
|
+
OTEL_EXPORTER_OTLP_HEADERS), then CODING_SH_URL + CODING_SH_INGEST_KEY. It also stamps
|
|
15
|
+
`gen_ai.conversation.id` on spans started inside coding_sh.with_session(...) or
|
|
16
|
+
coding_sh.trace(session_id=...), so OTel spans group into the same conversation session.
|
|
17
|
+
|
|
18
|
+
Needs `pip install coding-sh[otel]` (opentelemetry-sdk, opentelemetry-exporter-otlp-proto-http).
|
|
19
|
+
Without them, configure() warns once and returns None; it never raises into the app.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
import urllib.parse
|
|
27
|
+
from typing import Any, Dict, Optional
|
|
28
|
+
|
|
29
|
+
from ._trace import current_session_id
|
|
30
|
+
|
|
31
|
+
log = logging.getLogger("coding_sh")
|
|
32
|
+
|
|
33
|
+
CONVERSATION_ATTR = "gen_ai.conversation.id"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _env(name: str) -> Optional[str]:
|
|
37
|
+
v = os.environ.get(name)
|
|
38
|
+
return v if v else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def otel_env(url: Optional[str] = None, key: Optional[str] = None, service: Optional[str] = None) -> Dict[str, str]:
|
|
42
|
+
"""OTEL_* exporter env vars that point any OTel SDK at a coding.sh server.
|
|
43
|
+
|
|
44
|
+
Defaults come from CODING_SH_URL, CODING_SH_INGEST_KEY and CODING_SH_SERVICE. The result
|
|
45
|
+
holds the ingest key: pass it to a child process, never log it.
|
|
46
|
+
"""
|
|
47
|
+
url = url or _env("CODING_SH_URL")
|
|
48
|
+
key = key or _env("CODING_SH_INGEST_KEY")
|
|
49
|
+
service = service or _env("CODING_SH_SERVICE") or _env("OTEL_SERVICE_NAME")
|
|
50
|
+
env: Dict[str, str] = {}
|
|
51
|
+
if url:
|
|
52
|
+
env["OTEL_EXPORTER_OTLP_ENDPOINT"] = url.rstrip("/")
|
|
53
|
+
env["OTEL_EXPORTER_OTLP_PROTOCOL"] = "http/protobuf"
|
|
54
|
+
if key:
|
|
55
|
+
env["OTEL_EXPORTER_OTLP_HEADERS"] = "Authorization=" + urllib.parse.quote("Bearer " + key)
|
|
56
|
+
if service:
|
|
57
|
+
env["OTEL_SERVICE_NAME"] = service
|
|
58
|
+
return env
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ConversationSpanProcessor:
|
|
62
|
+
"""Sets gen_ai.conversation.id from the current coding.sh session on every new span."""
|
|
63
|
+
|
|
64
|
+
def on_start(self, span: Any, parent_context: Any = None) -> None:
|
|
65
|
+
try:
|
|
66
|
+
sid = current_session_id()
|
|
67
|
+
if not sid:
|
|
68
|
+
return
|
|
69
|
+
attrs = getattr(span, "attributes", None) or {}
|
|
70
|
+
if CONVERSATION_ATTR not in attrs and "session.id" not in attrs:
|
|
71
|
+
span.set_attribute(CONVERSATION_ATTR, sid)
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
def on_end(self, span: Any) -> None:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
def _on_ending(self, span: Any) -> None: # newer SDKs call this hook
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
def shutdown(self) -> None:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_warned = False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def configure(
|
|
92
|
+
service: Optional[str] = None,
|
|
93
|
+
url: Optional[str] = None,
|
|
94
|
+
key: Optional[str] = None,
|
|
95
|
+
set_global: bool = True,
|
|
96
|
+
batch: bool = True,
|
|
97
|
+
) -> Any:
|
|
98
|
+
"""Build (and by default install) a TracerProvider that exports to coding.sh.
|
|
99
|
+
|
|
100
|
+
Returns the provider, or None when OpenTelemetry is not installed or no endpoint is known.
|
|
101
|
+
Call provider.shutdown() (or rely on the SDK's atexit hook) to flush on exit.
|
|
102
|
+
"""
|
|
103
|
+
global _warned
|
|
104
|
+
try:
|
|
105
|
+
from opentelemetry import trace as otel_trace
|
|
106
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
107
|
+
from opentelemetry.sdk.resources import Resource
|
|
108
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
109
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SimpleSpanProcessor
|
|
110
|
+
except Exception:
|
|
111
|
+
if not _warned:
|
|
112
|
+
_warned = True
|
|
113
|
+
log.warning("coding.sh: OpenTelemetry is not installed; pip install 'coding-sh[otel]'")
|
|
114
|
+
return None
|
|
115
|
+
try:
|
|
116
|
+
exporter_kwargs: Dict[str, Any] = {}
|
|
117
|
+
has_otel_endpoint = bool(_env("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") or _env("OTEL_EXPORTER_OTLP_ENDPOINT"))
|
|
118
|
+
base = url or (None if has_otel_endpoint else _env("CODING_SH_URL"))
|
|
119
|
+
if base:
|
|
120
|
+
base = base.rstrip("/")
|
|
121
|
+
exporter_kwargs["endpoint"] = base if base.endswith("/v1/traces") else base + "/v1/traces"
|
|
122
|
+
elif not has_otel_endpoint:
|
|
123
|
+
if not _warned:
|
|
124
|
+
_warned = True
|
|
125
|
+
log.warning("coding.sh: no endpoint; set CODING_SH_URL or OTEL_EXPORTER_OTLP_ENDPOINT")
|
|
126
|
+
return None
|
|
127
|
+
k = key or (None if (has_otel_endpoint and not url) else _env("CODING_SH_INGEST_KEY"))
|
|
128
|
+
if k:
|
|
129
|
+
exporter_kwargs["headers"] = {"Authorization": "Bearer " + k}
|
|
130
|
+
name = service or _env("OTEL_SERVICE_NAME") or _env("CODING_SH_SERVICE")
|
|
131
|
+
resource = Resource.create({"service.name": name} if name else {})
|
|
132
|
+
provider = TracerProvider(resource=resource)
|
|
133
|
+
provider.add_span_processor(ConversationSpanProcessor()) # type: ignore[arg-type]
|
|
134
|
+
exporter = OTLPSpanExporter(**exporter_kwargs)
|
|
135
|
+
provider.add_span_processor(BatchSpanProcessor(exporter) if batch else SimpleSpanProcessor(exporter))
|
|
136
|
+
if set_global:
|
|
137
|
+
otel_trace.set_tracer_provider(provider)
|
|
138
|
+
return provider
|
|
139
|
+
except Exception as e:
|
|
140
|
+
log.warning("coding.sh: OpenTelemetry setup failed: %s", type(e).__name__)
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
__all__ = ["configure", "otel_env", "ConversationSpanProcessor", "CONVERSATION_ATTR"]
|
coding_sh/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: coding-sh
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: Trace any AI agent or LLM app from Python: send model calls, traces and tool spans to a coding.sh server.
|
|
5
|
+
Author: coding.sh contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://coding.sh
|
|
8
|
+
Project-URL: Repository, https://github.com/jackulau/coding.sh
|
|
9
|
+
Project-URL: Source, https://github.com/jackulau/coding.sh/tree/main/packages/sdk-python
|
|
10
|
+
Project-URL: Issues, https://github.com/jackulau/coding.sh/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/jackulau/coding.sh/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: ai agents,observability,evals,opentelemetry,llm,claude code,codex,openai,anthropic,tracing
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Topic :: System :: Monitoring
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Provides-Extra: openai
|
|
23
|
+
Requires-Dist: openai>=1.0; extra == "openai"
|
|
24
|
+
Provides-Extra: anthropic
|
|
25
|
+
Requires-Dist: anthropic>=0.30; extra == "anthropic"
|
|
26
|
+
Provides-Extra: otel
|
|
27
|
+
Requires-Dist: opentelemetry-sdk>=1.20; extra == "otel"
|
|
28
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.20; extra == "otel"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# coding-sh (Python SDK)
|
|
32
|
+
|
|
33
|
+
Send LLM calls, traces and tool spans from any Python service to a
|
|
34
|
+
[coding.sh](https://coding.sh) server (`coding.sh serve`). Stdlib only, Python 3.9+.
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
pip install coding-sh
|
|
38
|
+
export CODING_SH_URL=https://obs.example.com CODING_SH_INGEST_KEY=csh_ik_...
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import coding_sh
|
|
43
|
+
from openai import OpenAI
|
|
44
|
+
|
|
45
|
+
client = coding_sh.observe(OpenAI()) # also AsyncOpenAI, Anthropic, AsyncAnthropic
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
That's it: every `chat.completions.create`, `responses.create` and `messages.create` /
|
|
49
|
+
`messages.stream` call (sync, async and streaming) becomes an `llm.call` with tokens, model,
|
|
50
|
+
latency, time to first token, stop reason and tool calls. The server prices it.
|
|
51
|
+
|
|
52
|
+
Without `CODING_SH_URL` the SDK does nothing. It never changes return values, errors or stream
|
|
53
|
+
semantics, and never raises into your app.
|
|
54
|
+
|
|
55
|
+
## Traces, tools and conversations
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
with coding_sh.trace("support-reply", session_id=conversation_id, user_id=user.id, input=question) as t:
|
|
59
|
+
with coding_sh.span("tool", "lookup_order", input={"id": 42}) as s:
|
|
60
|
+
s.set_output(orders.get(42))
|
|
61
|
+
reply = client.chat.completions.create(model="gpt-4o-mini", messages=msgs)
|
|
62
|
+
t.set_output(reply.choices[0].message.content)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- Each top-level `trace()` (or untraced call) is its own session. Pass `session_id` (or wrap code
|
|
66
|
+
in `coding_sh.with_session(id)`) to make each trace a turn of one conversation.
|
|
67
|
+
- `trace` and `span` are context managers (`with` / `async with`) and decorators. A decorated
|
|
68
|
+
`span("tool", ...)` records the call's arguments as input and its return value as output.
|
|
69
|
+
- `span("step", name)` records a generic step.
|
|
70
|
+
- Context follows `contextvars`, so it crosses `await` and asyncio tasks. Work handed to a thread
|
|
71
|
+
pool starts untraced unless you run it with `contextvars.copy_context().run`.
|
|
72
|
+
|
|
73
|
+
## LLM gateways
|
|
74
|
+
|
|
75
|
+
Point the client at a gateway and each call records which one it went through, and the request id
|
|
76
|
+
the gateway's lookup API takes, so `coding.sh gateway enrich` can add the billed cost and
|
|
77
|
+
`coding.sh sessions --flag gateway:<name>` finds the sessions. Detected: Vercel AI Gateway,
|
|
78
|
+
OpenRouter, LiteLLM, Cloudflare AI Gateway, Portkey, Helicone, Requesty, Ramp Router (router.com),
|
|
79
|
+
TrueFoundry, Kong, Bifrost and Martian, by base URL (a self-hosted gateway by its URL env var, e.g.
|
|
80
|
+
`LITELLM_PROXY_API_BASE`), by response header (`cf-aig-log-id`, `helicone-id`, `x-litellm-call-id`,
|
|
81
|
+
`x-portkey-trace-id`, …) or by response id (`gen_…`, `gen-…`, `rqsty-…`).
|
|
82
|
+
`observe()` adds a response hook to the client's HTTP client that reads headers only, and only for
|
|
83
|
+
calls it records. [Details](https://github.com/jackulau/coding.sh/blob/main/docs/integrations/ai-gateway.md#from-the-sdks).
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
client = coding_sh.observe(OpenAI(base_url="https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/compat"))
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Delivery
|
|
90
|
+
|
|
91
|
+
One daemon thread sends batches (up to 500 events or 4 MiB, gzip) to `POST /v1/ingest`. It retries
|
|
92
|
+
429, 5xx and network errors (honoring `Retry-After`, else 1, 2, 4 ... s, at most 5 attempts) with
|
|
93
|
+
stable event ids, so retries never duplicate. 400/401/403 drop the batch with one warning. A full
|
|
94
|
+
queue drops new events and counts them. An `atexit` flush is bounded to 2 s.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
coding_sh.flush(timeout=5) # e.g. at the end of a script, a job or a serverless handler
|
|
98
|
+
coding_sh.shutdown() # flush, then stop
|
|
99
|
+
coding_sh.stats() # {"queued", "sent", "dropped", "failed"}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Config
|
|
103
|
+
|
|
104
|
+
`coding_sh.init(url=, key=, service=, capture=, flush_interval_ms=1000, max_batch=500, max_queue=10000, disabled=, debug=)`
|
|
105
|
+
is optional; arguments override env vars.
|
|
106
|
+
|
|
107
|
+
| Env | Meaning |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `CODING_SH_URL` | Server base URL. Unset = SDK off. |
|
|
110
|
+
| `CODING_SH_INGEST_KEY` | `csh_ik_...` ingest key. It decides the project. |
|
|
111
|
+
| `CODING_SH_SERVICE` (or `OTEL_SERVICE_NAME`) | Service name; becomes the session's agent id. |
|
|
112
|
+
| `CODING_SH_CAPTURE_MODE` | `full` (default) or `metadata` (no message text or tool I/O). |
|
|
113
|
+
| `CODING_SH_DISABLED=1` | Turn the SDK off. |
|
|
114
|
+
| `CODING_SH_DEBUG=1` | Log transport details (never keys or bodies). |
|
|
115
|
+
|
|
116
|
+
## OpenTelemetry
|
|
117
|
+
|
|
118
|
+
Already emitting gen_ai spans? Point OTel at the server instead (no SDK needed), or let the
|
|
119
|
+
helper do the setup:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from coding_sh.otel import configure, otel_env
|
|
123
|
+
configure(service="checkout-api") # TracerProvider + OTLP/HTTP exporter + Bearer key
|
|
124
|
+
env = otel_env() # OTEL_EXPORTER_OTLP_* vars for a subprocess
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`pip install 'coding-sh[otel]'`. `configure()` uses the standard `OTEL_EXPORTER_OTLP_*` env vars
|
|
128
|
+
when set, else `CODING_SH_URL` / `CODING_SH_INGEST_KEY`, and stamps `gen_ai.conversation.id` from
|
|
129
|
+
the current `with_session` / `trace(session_id=...)`.
|
|
130
|
+
|
|
131
|
+
## Tests
|
|
132
|
+
|
|
133
|
+
```sh
|
|
134
|
+
python3 -m unittest discover -s tests -t tests
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The provider tests use fake clients and always run. Tests against the real `openai`, `anthropic`
|
|
138
|
+
and OpenTelemetry packages run when those are installed.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
coding_sh/__init__.py,sha256=dtZ9V6DB_5J7MSuH3WE-1ZsW3RxZ9t-YWMeKgD3jNlM,1107
|
|
2
|
+
coding_sh/_client.py,sha256=xlyxT7ynsXs7SEdCOGwN0ljr1uvRJcSaYU3-emCO5YM,15877
|
|
3
|
+
coding_sh/_gateway.py,sha256=uzSNl9C-9n0q3M6V51owLScK0wQcB8kh2qrv2-JDjQ8,5401
|
|
4
|
+
coding_sh/_gateway_table.py,sha256=NeohPsRPgNhl7EBPy3u39eb5-mvbOPQ3JQbLfoMy-xk,3516
|
|
5
|
+
coding_sh/_llm.py,sha256=pOwT5YTA7dyJ_dAGptICy8vQVlhPvF9hp76pqMXEDwQ,11003
|
|
6
|
+
coding_sh/_providers.py,sha256=Kz2tXqctbysTt3eZpd5ws1wXE1DoIhE74gCodeKlB_g,19195
|
|
7
|
+
coding_sh/_trace.py,sha256=OdSOn_IiX_EsaItF1W_MKRVaE3-qy2d80f6icqeILBs,21979
|
|
8
|
+
coding_sh/otel.py,sha256=_eq1E6ae5Yk-OGwja9f187y2BkUeFg_uV8R5Vp5SrJk,5773
|
|
9
|
+
coding_sh/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
coding_sh-0.1.3.dist-info/licenses/LICENSE,sha256=uDSs7rzgBZqwq0KlOzhLZnBPeBfTcGI8ijcPbxQ2tHc,1079
|
|
11
|
+
coding_sh-0.1.3.dist-info/METADATA,sha256=ZFS0kS9YNrILVv8X_mgOhRqQAlU3sVM4NMYmBBj8erk,6318
|
|
12
|
+
coding_sh-0.1.3.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
13
|
+
coding_sh-0.1.3.dist-info/top_level.txt,sha256=rbbP9kA9i8EteSHOy7U6-bB9AQetzeu17i-Gluy684U,10
|
|
14
|
+
coding_sh-0.1.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 coding.sh contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
coding_sh
|