scopecall-py 0.2.0__tar.gz
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.
- scopecall_py-0.2.0/.gitignore +1 -0
- scopecall_py-0.2.0/LICENSE +121 -0
- scopecall_py-0.2.0/PKG-INFO +521 -0
- scopecall_py-0.2.0/README.md +482 -0
- scopecall_py-0.2.0/examples/fastapi/README.md +61 -0
- scopecall_py-0.2.0/examples/fastapi/app.py +200 -0
- scopecall_py-0.2.0/pyproject.toml +73 -0
- scopecall_py-0.2.0/scopecall/__init__.py +81 -0
- scopecall_py-0.2.0/scopecall/_config.py +129 -0
- scopecall_py-0.2.0/scopecall/_context.py +131 -0
- scopecall_py-0.2.0/scopecall/_exporter.py +273 -0
- scopecall_py-0.2.0/scopecall/_pricing.py +69 -0
- scopecall_py-0.2.0/scopecall/_redactor.py +80 -0
- scopecall_py-0.2.0/scopecall/_sdk.py +518 -0
- scopecall_py-0.2.0/scopecall/_version.py +7 -0
- scopecall_py-0.2.0/scopecall/instrumentation/__init__.py +13 -0
- scopecall_py-0.2.0/scopecall/instrumentation/_anthropic.py +517 -0
- scopecall_py-0.2.0/scopecall/instrumentation/_common.py +243 -0
- scopecall_py-0.2.0/scopecall/instrumentation/_openai.py +528 -0
- scopecall_py-0.2.0/scopecall/transport/__init__.py +0 -0
- scopecall_py-0.2.0/scopecall/wire/__init__.py +5 -0
- scopecall_py-0.2.0/scopecall/wire/_event.py +171 -0
- scopecall_py-0.2.0/tests/conftest.py +36 -0
- scopecall_py-0.2.0/tests/test_anthropic_instrumentation.py +518 -0
- scopecall_py-0.2.0/tests/test_config.py +115 -0
- scopecall_py-0.2.0/tests/test_context.py +144 -0
- scopecall_py-0.2.0/tests/test_event.py +141 -0
- scopecall_py-0.2.0/tests/test_openai_instrumentation.py +479 -0
- scopecall_py-0.2.0/tests/test_redaction.py +212 -0
- scopecall_py-0.2.0/tests/test_streaming_context.py +245 -0
- scopecall_py-0.2.0/tests/test_workflow_emission.py +206 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.venv/
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Business Source License 1.1
|
|
2
|
+
|
|
3
|
+
Licensor: ScopeCall Inc.
|
|
4
|
+
Licensed Work: ScopeCall
|
|
5
|
+
The Licensed Work is (c) 2026 ScopeCall Inc.
|
|
6
|
+
Additional Use Grant: You may make production use of the Licensed Work, provided
|
|
7
|
+
such use does not include offering the Licensed Work to
|
|
8
|
+
third parties on a hosted or embedded basis in order to
|
|
9
|
+
compete with ScopeCall's paid service offerings.
|
|
10
|
+
Change Date: May 26, 2031
|
|
11
|
+
Change License: Apache License, Version 2.0
|
|
12
|
+
|
|
13
|
+
For information about alternative licensing arrangements for the Licensed Work,
|
|
14
|
+
please contact: legal@scopecall.com
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
Business Source License 1.1
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
|
|
22
|
+
Licensor: ScopeCall Inc.
|
|
23
|
+
|
|
24
|
+
Licensed Work: ScopeCall. The Licensed Work is (c) 2026 ScopeCall Inc.
|
|
25
|
+
|
|
26
|
+
Additional Use Grant: You may make production use of the Licensed Work, provided
|
|
27
|
+
such use does not include offering the Licensed Work to third parties on a hosted
|
|
28
|
+
or embedded basis in order to compete with ScopeCall's paid service offerings.
|
|
29
|
+
|
|
30
|
+
For the avoidance of doubt, you may:
|
|
31
|
+
- Self-host ScopeCall for your own organization's internal use.
|
|
32
|
+
- Modify and contribute back to the Licensed Work.
|
|
33
|
+
- Build applications that send data to a self-hosted ScopeCall instance.
|
|
34
|
+
|
|
35
|
+
You may not:
|
|
36
|
+
- Offer ScopeCall as a managed service to third parties.
|
|
37
|
+
- White-label ScopeCall and resell it as your own product.
|
|
38
|
+
|
|
39
|
+
Change Date: May 26, 2031
|
|
40
|
+
|
|
41
|
+
Change License: Apache License, Version 2.0
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
License text
|
|
46
|
+
|
|
47
|
+
Business Source License 1.1
|
|
48
|
+
|
|
49
|
+
"Licensed Work" means the software made available by the Licensor under this
|
|
50
|
+
License.
|
|
51
|
+
|
|
52
|
+
"Licensor" means the licensor of the Licensed Work.
|
|
53
|
+
|
|
54
|
+
"Additional Use Grant" means the additional use grant defined in the Parameters.
|
|
55
|
+
|
|
56
|
+
"Change Date" means the date specified in the Parameters.
|
|
57
|
+
|
|
58
|
+
"Change License" means the license specified in the Parameters.
|
|
59
|
+
|
|
60
|
+
On the Change Date, or the fourth anniversary of the first publicly available
|
|
61
|
+
distribution of a specific version of the Licensed Work under this License,
|
|
62
|
+
whichever comes first, the Licensor hereby grants you rights under the terms of
|
|
63
|
+
the Change License, and the rights granted in the paragraph below terminate.
|
|
64
|
+
|
|
65
|
+
Subject to the Additional Use Grant, from the first day you receive a copy of
|
|
66
|
+
the Licensed Work under this License, through the Change Date, the Licensor
|
|
67
|
+
grants you a non-exclusive, worldwide, non-sublicensable, non-transferable,
|
|
68
|
+
royalty-free limited license to copy, modify, display, perform, and distribute
|
|
69
|
+
the Licensed Work for the purpose of your internal, non-commercial use.
|
|
70
|
+
|
|
71
|
+
Notice: If your use of the Licensed Work does not comply with the requirements
|
|
72
|
+
currently in effect as described in this License, you must purchase a commercial
|
|
73
|
+
license from the Licensor, its affiliated entities, or authorized resellers, or
|
|
74
|
+
you must refrain from using the Licensed Work.
|
|
75
|
+
|
|
76
|
+
All copies of the original and modified Licensed Work, and derivative works of
|
|
77
|
+
the Licensed Work, are subject to this License. This License applies separately
|
|
78
|
+
for each version of the Licensed Work, and the Change Date may vary for each
|
|
79
|
+
version of the Licensed Work released by Licensor.
|
|
80
|
+
|
|
81
|
+
You must conspicuously display this License on each original or modified copy of
|
|
82
|
+
the Licensed Work. If you receive the Licensed Work in original or modified form
|
|
83
|
+
from a third party, the terms and conditions set forth in this License apply to
|
|
84
|
+
your use of that work.
|
|
85
|
+
|
|
86
|
+
Any use of the Licensed Work in violation of this License will automatically
|
|
87
|
+
terminate your rights under this License for the current and all other versions
|
|
88
|
+
of the Licensed Work.
|
|
89
|
+
|
|
90
|
+
This License does not grant you any right in any trademark or logo of Licensor
|
|
91
|
+
or its affiliates (provided that you may use a trademark or logo of Licensor as
|
|
92
|
+
expressly required by this License).
|
|
93
|
+
|
|
94
|
+
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN
|
|
95
|
+
"AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS
|
|
96
|
+
OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
97
|
+
FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE.
|
|
98
|
+
|
|
99
|
+
MariaDB hereby grants you permission to use this License's text to license your
|
|
100
|
+
works, and to refer to it using the trademark "Business Source License", as long
|
|
101
|
+
as you comply with the Covenants of Licensor below.
|
|
102
|
+
|
|
103
|
+
Covenants of Licensor
|
|
104
|
+
|
|
105
|
+
In consideration of the right to use this License's text and the "Business Source
|
|
106
|
+
License" name and trademark, Licensor covenants to MariaDB, and to all who
|
|
107
|
+
receive notice of this covenant:
|
|
108
|
+
|
|
109
|
+
1. To specify as the Change License the GPL Version 2.0 or any later version, or
|
|
110
|
+
a license that is compatible with GPL Version 2.0 or a later version, where
|
|
111
|
+
"compatible" means that software provided under the Change License can be
|
|
112
|
+
included in a program with software provided under GPL Version 2.0 or a later
|
|
113
|
+
version. Licensor may specify additional Change Licenses without limitation.
|
|
114
|
+
|
|
115
|
+
2. To either: (a) specify an additional grant of rights to use that does not
|
|
116
|
+
impose any additional restriction on the right granted in this License, as the
|
|
117
|
+
Additional Use Grant; or (b) insert the text "None".
|
|
118
|
+
|
|
119
|
+
3. To specify a Change Date.
|
|
120
|
+
|
|
121
|
+
4. Not to modify this License in any other way.
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scopecall-py
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Source-available, self-hostable AI observability — scope every LLM call in production
|
|
5
|
+
Project-URL: Homepage, https://scopecall.com
|
|
6
|
+
Project-URL: Repository, https://github.com/scopecall/scopecall
|
|
7
|
+
Project-URL: Documentation, https://docs.scopecall.com
|
|
8
|
+
Project-URL: Issues, https://github.com/scopecall/scopecall/issues
|
|
9
|
+
Author-email: ScopeCall <founders@scopecall.com>
|
|
10
|
+
License: BUSL-1.1
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai,anthropic,cost,llm,observability,openai,scopecall,tracing
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Topic :: System :: Monitoring
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: httpx>=0.24.0
|
|
24
|
+
Requires-Dist: typing-extensions>=4.0.0; python_version < '3.11'
|
|
25
|
+
Provides-Extra: all
|
|
26
|
+
Requires-Dist: anthropic>=0.20.0; extra == 'all'
|
|
27
|
+
Requires-Dist: openai>=1.0.0; extra == 'all'
|
|
28
|
+
Provides-Extra: anthropic
|
|
29
|
+
Requires-Dist: anthropic>=0.20.0; extra == 'anthropic'
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: httpx>=0.24.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
35
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
36
|
+
Provides-Extra: openai
|
|
37
|
+
Requires-Dist: openai>=1.0.0; extra == 'openai'
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
40
|
+
# scopecall
|
|
41
|
+
|
|
42
|
+
Python SDK for [ScopeCall](https://scopecall.com) — source-available, self-hostable AI cost and workflow observability.
|
|
43
|
+
|
|
44
|
+
[](https://pypi.org/project/scopecall-py/)
|
|
45
|
+
[](LICENSE)
|
|
46
|
+
[](https://pypi.org/project/scopecall-py/)
|
|
47
|
+
|
|
48
|
+
Wraps the OpenAI and Anthropic Python clients so every LLM call shows
|
|
49
|
+
up in your ScopeCall dashboard with cost, latency, prompt-version, and
|
|
50
|
+
workflow-tree attribution — **without** routing traffic through a
|
|
51
|
+
proxy.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install scopecall-py
|
|
59
|
+
|
|
60
|
+
# Or with provider extras (recommended — pins to a known-good lower bound):
|
|
61
|
+
pip install "scopecall-py[openai]"
|
|
62
|
+
pip install "scopecall-py[anthropic]"
|
|
63
|
+
pip install "scopecall-py[all]"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The PyPI package is named `scopecall-py` (Supabase-style language
|
|
67
|
+
suffix); the Python import name stays just `scopecall`. So you `pip
|
|
68
|
+
install scopecall-py` and then `from scopecall import init`.
|
|
69
|
+
|
|
70
|
+
Python 3.10+ required.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Quick start
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import scopecall
|
|
78
|
+
from openai import OpenAI
|
|
79
|
+
|
|
80
|
+
# Initialize once at app startup.
|
|
81
|
+
sdk = scopecall.init(
|
|
82
|
+
api_key="sc_live_xxx", # from your ScopeCall dashboard
|
|
83
|
+
endpoint="http://localhost:8080/v1/ingest", # required: self-hosted ingest URL
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Wrap the OpenAI client — every chat.completions.create call is now traced.
|
|
87
|
+
openai_client = sdk.instrument(OpenAI())
|
|
88
|
+
|
|
89
|
+
with sdk.trace("support-agent", user_id="user_123") as ctx:
|
|
90
|
+
response = openai_client.chat.completions.create(
|
|
91
|
+
model="gpt-4o-mini",
|
|
92
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Traces appear in your dashboard within seconds.
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
> **No hosted-Cloud default yet.** A managed default endpoint will
|
|
99
|
+
> return when ScopeCall Cloud is live. Until then, `init()` requires
|
|
100
|
+
> `endpoint` to be set explicitly when using `api_key` — fail-fast is
|
|
101
|
+
> safer than silently sending events to a domain that doesn't exist.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Configuration
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
sdk = scopecall.init(
|
|
109
|
+
api_key="sc_live_xxx", # required (or use debug=True / output=<path>)
|
|
110
|
+
endpoint="http://localhost:8080/v1/ingest", # required when using api_key
|
|
111
|
+
environment="production", # optional; defaults to "production"
|
|
112
|
+
capture_content=True, # optional; record prompts/completions (default True)
|
|
113
|
+
redact_pii=True, # optional; PII redaction (default True)
|
|
114
|
+
batch_size=50, # optional; events per HTTP batch
|
|
115
|
+
max_retries=3, # optional; retry attempts on transient failure
|
|
116
|
+
flush_interval=5.0, # optional; seconds between auto-flush
|
|
117
|
+
debug=False, # optional; route events to stdout instead of HTTP
|
|
118
|
+
)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Other transport modes:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
# Console mode — pretty-prints events to stdout. Useful during integration.
|
|
125
|
+
sdk = scopecall.init(debug=True)
|
|
126
|
+
|
|
127
|
+
# File mode — appends NDJSON events to a path. Useful for offline capture.
|
|
128
|
+
sdk = scopecall.init(output="/var/log/scopecall.ndjson")
|
|
129
|
+
|
|
130
|
+
# Disabled mode — no-op SDK that swallows every call. Useful in tests.
|
|
131
|
+
sdk = scopecall.init(disabled=True)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Anthropic
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
import scopecall
|
|
140
|
+
import anthropic
|
|
141
|
+
|
|
142
|
+
sdk = scopecall.init(
|
|
143
|
+
api_key="sc_live_xxx",
|
|
144
|
+
endpoint="http://localhost:8080/v1/ingest",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
anthropic_client = sdk.instrument(anthropic.Anthropic(), provider="anthropic")
|
|
148
|
+
|
|
149
|
+
msg = anthropic_client.messages.create(
|
|
150
|
+
model="claude-3-5-sonnet-20241022",
|
|
151
|
+
max_tokens=1024,
|
|
152
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
153
|
+
)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Streaming works the same way — pass `stream=True` and iterate. TTFT
|
|
157
|
+
(time to first token) is captured automatically; output content is
|
|
158
|
+
assembled from `content_block_delta` events; final token counts come
|
|
159
|
+
from the `message_delta` event Anthropic emits near end-of-stream.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Async
|
|
164
|
+
|
|
165
|
+
Both `AsyncOpenAI` and `AsyncAnthropic` are first-class — `instrument()`
|
|
166
|
+
auto-detects async vs sync from the client and wraps accordingly. No
|
|
167
|
+
separate API.
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
import asyncio
|
|
171
|
+
import scopecall
|
|
172
|
+
from openai import AsyncOpenAI
|
|
173
|
+
|
|
174
|
+
sdk = scopecall.init(
|
|
175
|
+
api_key="sc_live_xxx",
|
|
176
|
+
endpoint="http://localhost:8080/v1/ingest",
|
|
177
|
+
)
|
|
178
|
+
client = sdk.instrument(AsyncOpenAI())
|
|
179
|
+
|
|
180
|
+
async def main():
|
|
181
|
+
# Use asyncio.gather so this snippet runs on Python 3.10 (the SDK's
|
|
182
|
+
# lower bound). asyncio.TaskGroup is 3.11+; if you're on 3.11 or
|
|
183
|
+
# later it's a cleaner choice for structured concurrency.
|
|
184
|
+
await asyncio.gather(*(
|
|
185
|
+
client.chat.completions.create(
|
|
186
|
+
model="gpt-4o-mini",
|
|
187
|
+
messages=[{"role": "user", "content": f"Hello {i}"}],
|
|
188
|
+
)
|
|
189
|
+
for i in range(3)
|
|
190
|
+
))
|
|
191
|
+
|
|
192
|
+
asyncio.run(main())
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`contextvars` propagate the active `sdk.trace()` context across
|
|
196
|
+
`await` and `asyncio.create_task()`, so concurrent calls inside the
|
|
197
|
+
same trace get the right `parent_span_id` automatically.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Workflow tracing
|
|
202
|
+
|
|
203
|
+
The `sdk.trace(name)` block emits a synthetic **workflow span** when it
|
|
204
|
+
exits, so the ScopeCall dashboard can render the parent → child
|
|
205
|
+
structure of multi-call agents:
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
with sdk.trace("rag-question", user_id=user_id, session_id=session_id):
|
|
209
|
+
# 1) retrieve documents (could itself be an LLM call)
|
|
210
|
+
docs = retriever.retrieve(question)
|
|
211
|
+
|
|
212
|
+
# 2) call the LLM with the retrieved context
|
|
213
|
+
response = openai_client.chat.completions.create(
|
|
214
|
+
model="gpt-4o-mini",
|
|
215
|
+
messages=[
|
|
216
|
+
{"role": "system", "content": f"Context:\n{docs}"},
|
|
217
|
+
{"role": "user", "content": question},
|
|
218
|
+
],
|
|
219
|
+
)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
In the dashboard's trace tree, that block renders as:
|
|
223
|
+
|
|
224
|
+
```
|
|
225
|
+
rag-question (workflow span)
|
|
226
|
+
└── chat.completions.create (LLM span)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Nested traces work too — the inner block inherits `trace_id`,
|
|
230
|
+
gets its own `span_id`, and sets `parent_span_id` to the outer block.
|
|
231
|
+
|
|
232
|
+
### Streaming + workflow latency
|
|
233
|
+
|
|
234
|
+
When a streaming response is iterated AFTER the enclosing
|
|
235
|
+
`sdk.trace()` block has exited (the common pattern with FastAPI's
|
|
236
|
+
`StreamingResponse`, where the route handler returns and the iterator
|
|
237
|
+
runs later), the SDK still attaches the child LLM event to the
|
|
238
|
+
workflow span correctly — context is snapshotted when
|
|
239
|
+
`.create()` is called, not when the stream is consumed.
|
|
240
|
+
|
|
241
|
+
But the workflow span's **latency** only covers what's inside the
|
|
242
|
+
`with` block. If you want workflow latency to reflect the full
|
|
243
|
+
streaming duration, keep the trace block open across the iteration:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
async def event_source():
|
|
247
|
+
with sdk.trace("chat-api", user_id=req.user_id):
|
|
248
|
+
stream = await openai_client.chat.completions.create(
|
|
249
|
+
model="gpt-4o-mini",
|
|
250
|
+
messages=messages,
|
|
251
|
+
stream=True,
|
|
252
|
+
)
|
|
253
|
+
async for chunk in stream:
|
|
254
|
+
yield chunk
|
|
255
|
+
|
|
256
|
+
return StreamingResponse(event_source(), media_type="text/event-stream")
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
The runnable FastAPI example below uses exactly this shape.
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## Per-call metadata
|
|
264
|
+
|
|
265
|
+
Set defaults SDK-wide on `init()`, then override per-trace:
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
sdk = scopecall.init(
|
|
269
|
+
api_key="sc_live_xxx",
|
|
270
|
+
endpoint="http://localhost:8080/v1/ingest",
|
|
271
|
+
default_feature="chat", # every call tagged "chat"
|
|
272
|
+
default_user_id="anonymous",
|
|
273
|
+
default_prompt_version=os.getenv("DEPLOY_SHA"), # auto-tag with commit hash
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# Per-call overrides win over defaults; nested-trace inheritance fills
|
|
277
|
+
# the gap for prompt_version (trace > parent > default > None).
|
|
278
|
+
with sdk.trace("billing-agent", user_id=user.id, prompt_version="refund-v3"):
|
|
279
|
+
...
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
## Prompt-version tracking
|
|
285
|
+
|
|
286
|
+
Tag each `sdk.trace()` with a `prompt_version`. The ScopeCall Prompts
|
|
287
|
+
page surfaces cost / latency / error-rate **per version** — ship a new
|
|
288
|
+
prompt, see whether output tokens went up:
|
|
289
|
+
|
|
290
|
+
```python
|
|
291
|
+
PROMPT_V = "refund-policy-v7"
|
|
292
|
+
|
|
293
|
+
with sdk.trace("support-agent", prompt_version=PROMPT_V):
|
|
294
|
+
response = openai_client.chat.completions.create(
|
|
295
|
+
model="gpt-4o",
|
|
296
|
+
messages=[
|
|
297
|
+
{"role": "system", "content": PROMPT_V_TEXT},
|
|
298
|
+
{"role": "user", "content": question},
|
|
299
|
+
],
|
|
300
|
+
)
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Nested traces inherit the parent's `prompt_version`. To clear it on a
|
|
304
|
+
child span, pass `prompt_version=None` explicitly (which doesn't
|
|
305
|
+
override; you'd want a different scope name instead).
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## Manual instrumentation (LangChain, LlamaIndex, custom)
|
|
310
|
+
|
|
311
|
+
If you're calling an LLM through a framework that wraps the underlying
|
|
312
|
+
client (LangChain, LlamaIndex, CrewAI, your own gateway), `instrument()`
|
|
313
|
+
can't see through to the raw call. Use `sdk.record_llm_call()` to emit
|
|
314
|
+
events manually — same wire format, same trace-context chaining:
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
with sdk.trace("rag-answer"):
|
|
318
|
+
docs = retriever.retrieve(q) # your code, not instrumented
|
|
319
|
+
|
|
320
|
+
# ... call your custom LLM wrapper ...
|
|
321
|
+
sdk.record_llm_call(
|
|
322
|
+
model="gpt-4o-mini",
|
|
323
|
+
provider="openai",
|
|
324
|
+
input_tokens=1234,
|
|
325
|
+
output_tokens=567,
|
|
326
|
+
latency_ms=842,
|
|
327
|
+
input_text=prompt,
|
|
328
|
+
output_text=answer,
|
|
329
|
+
finish_reason="stop",
|
|
330
|
+
)
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
`record_llm_call` reads the current `sdk.trace()` context to set
|
|
334
|
+
`parent_span_id` and inherit feature / user / session / prompt_version.
|
|
335
|
+
PII redaction (`redact_pii=True`) applies to manual calls too — input
|
|
336
|
+
and output run through the same scrubber the auto-instrumented path
|
|
337
|
+
uses.
|
|
338
|
+
|
|
339
|
+
For deeper sub-step instrumentation (e.g. "retrieve" and "rerank" as
|
|
340
|
+
separate visible spans), nest `sdk.trace()` blocks rather than reaching
|
|
341
|
+
for a sub-span helper. Each nested `trace` block emits its own
|
|
342
|
+
workflow span and chains correctly:
|
|
343
|
+
|
|
344
|
+
```python
|
|
345
|
+
with sdk.trace("rag-answer"):
|
|
346
|
+
with sdk.trace("retrieve"):
|
|
347
|
+
docs = retriever.retrieve(q)
|
|
348
|
+
with sdk.trace("generate"):
|
|
349
|
+
sdk.record_llm_call(...)
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
## FastAPI
|
|
355
|
+
|
|
356
|
+
```python
|
|
357
|
+
from contextlib import asynccontextmanager
|
|
358
|
+
|
|
359
|
+
import scopecall
|
|
360
|
+
from fastapi import FastAPI
|
|
361
|
+
from openai import AsyncOpenAI
|
|
362
|
+
|
|
363
|
+
sdk: scopecall.ScopeCallSDK
|
|
364
|
+
client: AsyncOpenAI
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@asynccontextmanager
|
|
368
|
+
async def lifespan(app: FastAPI):
|
|
369
|
+
"""Initialize the SDK once at startup; close on shutdown so the
|
|
370
|
+
background flush thread drains pending events before exit."""
|
|
371
|
+
global sdk, client
|
|
372
|
+
sdk = scopecall.init(
|
|
373
|
+
api_key=os.environ["SCOPECALL_API_KEY"],
|
|
374
|
+
endpoint=os.environ.get(
|
|
375
|
+
"SCOPECALL_ENDPOINT", "http://localhost:8080/v1/ingest"
|
|
376
|
+
),
|
|
377
|
+
environment=os.environ.get("ENV", "production"),
|
|
378
|
+
default_prompt_version=os.environ.get("DEPLOY_SHA"),
|
|
379
|
+
)
|
|
380
|
+
client = sdk.instrument(AsyncOpenAI())
|
|
381
|
+
yield
|
|
382
|
+
sdk.close(timeout=5.0)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
app = FastAPI(lifespan=lifespan)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@app.post("/chat")
|
|
389
|
+
async def chat(req: ChatRequest):
|
|
390
|
+
with sdk.trace("chat-api", user_id=req.user_id, session_id=req.session_id):
|
|
391
|
+
response = await client.chat.completions.create(
|
|
392
|
+
model="gpt-4o-mini",
|
|
393
|
+
messages=req.messages,
|
|
394
|
+
)
|
|
395
|
+
return {"reply": response.choices[0].message.content}
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
A runnable version of this example lives in
|
|
399
|
+
[`examples/fastapi/`](examples/fastapi/).
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
## What gets captured
|
|
404
|
+
|
|
405
|
+
Every traced LLM call captures:
|
|
406
|
+
|
|
407
|
+
| Field | Description |
|
|
408
|
+
|-------|-------------|
|
|
409
|
+
| `model` | Canonical model name (e.g. `gpt-4o-mini`, `claude-3-5-sonnet-20241022`) |
|
|
410
|
+
| `provider` | `openai` or `anthropic` |
|
|
411
|
+
| `input_tokens` | Prompt token count |
|
|
412
|
+
| `output_tokens` | Completion token count |
|
|
413
|
+
| `cache_read_tokens` | OpenAI prompt cache hits / Anthropic `cache_read_input_tokens` |
|
|
414
|
+
| `cost_usd` | Computed server-side from the bundled pricing table |
|
|
415
|
+
| `latency_ms` | End-to-end latency |
|
|
416
|
+
| `ttft_ms` | Time to first token (streaming only) |
|
|
417
|
+
| `finish_reason` | `stop` / `length` / `tool_calls` / `end_turn` (Anthropic) |
|
|
418
|
+
| `status` | `success` / `error` / `timeout` / `rate_limited` |
|
|
419
|
+
| `error_message` | Error detail on failure |
|
|
420
|
+
| `input_text` | Full prompt (redacted per your PII config) |
|
|
421
|
+
| `output_text` | Full completion |
|
|
422
|
+
| `tool_calls` | Tool-use blocks as JSON (Anthropic) |
|
|
423
|
+
| `prompt_version` | Per-trace label from `sdk.trace()` or config — powers the Prompts page |
|
|
424
|
+
| `feature_name` / `user_id` / `session_id` | From `sdk.trace()` or `init()` defaults |
|
|
425
|
+
| `kind` | `llm` for provider calls, `workflow` for `sdk.trace()` blocks |
|
|
426
|
+
|
|
427
|
+
---
|
|
428
|
+
|
|
429
|
+
## PII redaction
|
|
430
|
+
|
|
431
|
+
When `redact_pii=True` (the default), `input_text` and `output_text`
|
|
432
|
+
pass through a regex-based scrubber before leaving the process. The
|
|
433
|
+
same scrubber runs on auto-instrumented `chat.completions.create` /
|
|
434
|
+
`messages.create` calls AND on manual `sdk.record_llm_call(...)` —
|
|
435
|
+
the policy is the same regardless of how the event was generated.
|
|
436
|
+
|
|
437
|
+
| Pattern | Replacement |
|
|
438
|
+
|---|---|
|
|
439
|
+
| Email | `[EMAIL]` |
|
|
440
|
+
| Credit card (Luhn-validated) | `[CARD]` |
|
|
441
|
+
| SSN | `[SSN]` |
|
|
442
|
+
| IPv4 | `[IP]` |
|
|
443
|
+
| Phone | `[PHONE]` |
|
|
444
|
+
|
|
445
|
+
Add custom patterns via the public helper on the SDK:
|
|
446
|
+
|
|
447
|
+
```python
|
|
448
|
+
sdk.add_redaction_pattern(
|
|
449
|
+
"UUID",
|
|
450
|
+
r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b",
|
|
451
|
+
)
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
To disable redaction entirely (rarely a good idea outside dev), pass
|
|
455
|
+
`redact_pii=False`.
|
|
456
|
+
|
|
457
|
+
---
|
|
458
|
+
|
|
459
|
+
## Providers
|
|
460
|
+
|
|
461
|
+
| Provider | Status |
|
|
462
|
+
|----------|--------|
|
|
463
|
+
| OpenAI (`chat.completions.create`) — sync + async + streaming | ✅ v0.2.0 |
|
|
464
|
+
| Anthropic (`messages.create`) — sync + async + streaming | ✅ v0.2.0 |
|
|
465
|
+
| Google Gemini | 🔜 v0.3 |
|
|
466
|
+
| LangChain (via manual API today; native bridge planned) | 🔜 v0.3 |
|
|
467
|
+
| LlamaIndex (via manual API today) | 🔜 v0.3 |
|
|
468
|
+
|
|
469
|
+
For unsupported providers / frameworks, use `sdk.record_llm_call(...)`
|
|
470
|
+
to emit events directly — the wire format is the same.
|
|
471
|
+
|
|
472
|
+
---
|
|
473
|
+
|
|
474
|
+
## Migrating from `scopecall` v0.1.x
|
|
475
|
+
|
|
476
|
+
v0.1 used module-level globals (`scopecall.init()` then
|
|
477
|
+
`scopecall.trace(...)`). v0.2 returns an instance from `init()`.
|
|
478
|
+
|
|
479
|
+
The two changes most likely to break callers:
|
|
480
|
+
|
|
481
|
+
```python
|
|
482
|
+
# v0.1 (old)
|
|
483
|
+
scopecall.init(api_key="...") # module-level
|
|
484
|
+
with scopecall.trace(feature="x"):
|
|
485
|
+
...
|
|
486
|
+
|
|
487
|
+
# v0.2 (new)
|
|
488
|
+
sdk = scopecall.init(api_key="...", # endpoint REQUIRED now
|
|
489
|
+
endpoint="http://localhost:8080/v1/ingest")
|
|
490
|
+
with sdk.trace("x"): # name is positional
|
|
491
|
+
...
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
Other notable changes:
|
|
495
|
+
|
|
496
|
+
- `endpoint` is required when `api_key` is set (no silent default to
|
|
497
|
+
`https://ingest.scopecall.com` because Cloud isn't live yet).
|
|
498
|
+
- Removed dependency on Traceloop / OpenLLMetry.
|
|
499
|
+
- Native OpenAI + Anthropic instrumentation (sync + async + streaming)
|
|
500
|
+
via `sdk.instrument(client)`.
|
|
501
|
+
- New manual API: `sdk.record_llm_call(...)` and `sdk.add_redaction_pattern(name, regex)`.
|
|
502
|
+
- `LLMEvent` wire format adds `kind`, `prompt_version`,
|
|
503
|
+
`input_cost_usd`, `output_cost_usd`, `finish_reason`,
|
|
504
|
+
`cache_read_tokens`, `tool_calls`, and others to match the TS SDK
|
|
505
|
+
parity contract.
|
|
506
|
+
|
|
507
|
+
---
|
|
508
|
+
|
|
509
|
+
## Self-hosted setup
|
|
510
|
+
|
|
511
|
+
See the [main repo README](https://github.com/scopecall/scopecall) for
|
|
512
|
+
the full Docker Compose quickstart that brings up the Rust ingest, Rust
|
|
513
|
+
processor, ClickHouse, Postgres, Redpanda, Go API, and Next.js
|
|
514
|
+
dashboard.
|
|
515
|
+
|
|
516
|
+
---
|
|
517
|
+
|
|
518
|
+
## License
|
|
519
|
+
|
|
520
|
+
[BUSL-1.1](LICENSE) — free for any internal use; not for resale as a
|
|
521
|
+
managed service. Converts to Apache 2.0 on May 26, 2031.
|