chatsee-claude-agent-sdk 0.1.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.
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: chatsee-claude-agent-sdk
3
+ Version: 0.1.0
4
+ Summary: ChatSee connector for the Claude Agent SDK — capture every turn with one line.
5
+ Maintainer-email: ChatSee <contact@chatsee.ai>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: chatsee-ai>=0.14.0
10
+ Requires-Dist: claude-agent-sdk>=0.2.100
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8.0; extra == "dev"
13
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
14
+ Requires-Dist: chatsee-connector-testing; extra == "dev"
15
+
16
+ # chatsee-claude-agent-sdk
17
+
18
+ ChatSee connector for the **Claude Agent SDK** — Anthropic's agent framework,
19
+ the one that powers Claude Code.
20
+
21
+ ```bash
22
+ pip install chatsee-claude-agent-sdk
23
+ ```
24
+
25
+ ```python
26
+ from chatsee_claude_agent_sdk import instrument_claude_agent_sdk
27
+
28
+ chatsee = instrument_claude_agent_sdk(agent_id="...", tenant_id="...")
29
+
30
+ async for message in chatsee.query(prompt="where is my order", options=opts):
31
+ ...
32
+ ```
33
+
34
+ `chatsee-ai` is installed for you as a dependency.
35
+
36
+ ## Not the same thing as `chatsee-anthropic`
37
+
38
+ | | `chatsee-anthropic` | this |
39
+ | --- | --- | --- |
40
+ | Package it hooks | `anthropic` | `claude-agent-sdk` |
41
+ | Who writes the agent loop | the customer | Anthropic |
42
+ | What we hook | `client.messages.create` | the `query()` message stream |
43
+
44
+ Use `chatsee-anthropic` when they call the Messages API and run their own
45
+ tool-use loop. Use this when they call `query()` and let the SDK run it.
46
+
47
+ ## Why a handle rather than a one-liner
48
+
49
+ There is no client object to patch — the entry point is a module-level
50
+ `query()`. So `instrument_claude_agent_sdk` returns a handle whose `.query` is
51
+ a drop-in: every message still reaches the caller, unchanged and in order,
52
+ while the turn is assembled alongside.
53
+
54
+ `claude_agent_sdk.query` is replaced too, so code calling it **through the
55
+ module** needs no change. Code that did `from claude_agent_sdk import query`
56
+ already holds its own reference to the original and cannot be reached that way
57
+ — that code uses the handle.
58
+
59
+ ## What it captures
60
+
61
+ Per turn: the prompt, the final answer, the system prompt, every tool call with
62
+ its arguments and result, and **one model call per model**, with tokens, cached
63
+ tokens and a real dollar cost.
64
+
65
+ This SDK is the only one that states its own cost (`total_cost_usd`), so that
66
+ figure is passed through rather than estimated. Nothing here invents a price,
67
+ and a turn's cost is never divided across models — a split we made up would
68
+ look like measurement.
69
+
70
+ ## Three things this framework does that nothing else does
71
+
72
+ **The CLI's system prompt is enormous.** Claude Code injects roughly 21k cached
73
+ tokens on every call, so a one-line "hello" costs around $0.027. Those land in
74
+ `cache_read_input_tokens`, not `input_tokens` — both are captured, or the turn
75
+ looks far cheaper than the bill.
76
+
77
+ **The CLI calls its own tools.** It loads tools on demand and emits a
78
+ `ToolSearch` tool-use alongside the agent's real one. Those are filtered: a
79
+ support agent that never touched a search tool must not show one in its trace.
80
+
81
+ **Tool names arrive MCP-qualified.** Custom tools reach the model as
82
+ `mcp__<server>__<tool>`. The customer named it `get_order_status`, so the
83
+ prefix is stripped and that is what the screens say.
84
+
85
+ ## Guarantees
86
+
87
+ * **Fails open.** A failure inside capture is logged and swallowed.
88
+ * **Never consumes the stream.** Every message is forwarded unchanged, in order.
89
+ * **Idempotent.** Instrumenting twice does not capture twice.
90
+ * **Concurrency-safe.** State is per `query()` call, not per handle.
91
+
92
+ All four are enforced by the contract suite, not just intended.
93
+
94
+ ## Tests
95
+
96
+ No network, no API key, no CLI subprocess — the SDK is stubbed, ChatSee is not.
97
+ That matters more here than anywhere else: a real turn costs about $0.027, so
98
+ finding a capture bug by running one would be slow and expensive.
99
+
100
+ ```bash
101
+ pip install -e python/connectors/_shared
102
+ pip install -e python/connectors/ai_sdk/claude_agent_sdk
103
+ pytest python/connectors/ai_sdk/claude_agent_sdk/tests -q
104
+ ```
105
+
106
+ ## Known gap
107
+
108
+ Turn-level `duration_ms` comes from the tracker's own wall clock, and all four
109
+ tracker calls are made together once the turn is known to be complete — so it
110
+ reads near zero. The honest latency is on the model call, from the SDK's
111
+ `duration_api_ms`. Fixing it properly means letting `end_turn` accept a
112
+ measured duration, which is a core-SDK change and not this connector's to make.
@@ -0,0 +1,97 @@
1
+ # chatsee-claude-agent-sdk
2
+
3
+ ChatSee connector for the **Claude Agent SDK** — Anthropic's agent framework,
4
+ the one that powers Claude Code.
5
+
6
+ ```bash
7
+ pip install chatsee-claude-agent-sdk
8
+ ```
9
+
10
+ ```python
11
+ from chatsee_claude_agent_sdk import instrument_claude_agent_sdk
12
+
13
+ chatsee = instrument_claude_agent_sdk(agent_id="...", tenant_id="...")
14
+
15
+ async for message in chatsee.query(prompt="where is my order", options=opts):
16
+ ...
17
+ ```
18
+
19
+ `chatsee-ai` is installed for you as a dependency.
20
+
21
+ ## Not the same thing as `chatsee-anthropic`
22
+
23
+ | | `chatsee-anthropic` | this |
24
+ | --- | --- | --- |
25
+ | Package it hooks | `anthropic` | `claude-agent-sdk` |
26
+ | Who writes the agent loop | the customer | Anthropic |
27
+ | What we hook | `client.messages.create` | the `query()` message stream |
28
+
29
+ Use `chatsee-anthropic` when they call the Messages API and run their own
30
+ tool-use loop. Use this when they call `query()` and let the SDK run it.
31
+
32
+ ## Why a handle rather than a one-liner
33
+
34
+ There is no client object to patch — the entry point is a module-level
35
+ `query()`. So `instrument_claude_agent_sdk` returns a handle whose `.query` is
36
+ a drop-in: every message still reaches the caller, unchanged and in order,
37
+ while the turn is assembled alongside.
38
+
39
+ `claude_agent_sdk.query` is replaced too, so code calling it **through the
40
+ module** needs no change. Code that did `from claude_agent_sdk import query`
41
+ already holds its own reference to the original and cannot be reached that way
42
+ — that code uses the handle.
43
+
44
+ ## What it captures
45
+
46
+ Per turn: the prompt, the final answer, the system prompt, every tool call with
47
+ its arguments and result, and **one model call per model**, with tokens, cached
48
+ tokens and a real dollar cost.
49
+
50
+ This SDK is the only one that states its own cost (`total_cost_usd`), so that
51
+ figure is passed through rather than estimated. Nothing here invents a price,
52
+ and a turn's cost is never divided across models — a split we made up would
53
+ look like measurement.
54
+
55
+ ## Three things this framework does that nothing else does
56
+
57
+ **The CLI's system prompt is enormous.** Claude Code injects roughly 21k cached
58
+ tokens on every call, so a one-line "hello" costs around $0.027. Those land in
59
+ `cache_read_input_tokens`, not `input_tokens` — both are captured, or the turn
60
+ looks far cheaper than the bill.
61
+
62
+ **The CLI calls its own tools.** It loads tools on demand and emits a
63
+ `ToolSearch` tool-use alongside the agent's real one. Those are filtered: a
64
+ support agent that never touched a search tool must not show one in its trace.
65
+
66
+ **Tool names arrive MCP-qualified.** Custom tools reach the model as
67
+ `mcp__<server>__<tool>`. The customer named it `get_order_status`, so the
68
+ prefix is stripped and that is what the screens say.
69
+
70
+ ## Guarantees
71
+
72
+ * **Fails open.** A failure inside capture is logged and swallowed.
73
+ * **Never consumes the stream.** Every message is forwarded unchanged, in order.
74
+ * **Idempotent.** Instrumenting twice does not capture twice.
75
+ * **Concurrency-safe.** State is per `query()` call, not per handle.
76
+
77
+ All four are enforced by the contract suite, not just intended.
78
+
79
+ ## Tests
80
+
81
+ No network, no API key, no CLI subprocess — the SDK is stubbed, ChatSee is not.
82
+ That matters more here than anywhere else: a real turn costs about $0.027, so
83
+ finding a capture bug by running one would be slow and expensive.
84
+
85
+ ```bash
86
+ pip install -e python/connectors/_shared
87
+ pip install -e python/connectors/ai_sdk/claude_agent_sdk
88
+ pytest python/connectors/ai_sdk/claude_agent_sdk/tests -q
89
+ ```
90
+
91
+ ## Known gap
92
+
93
+ Turn-level `duration_ms` comes from the tracker's own wall clock, and all four
94
+ tracker calls are made together once the turn is known to be complete — so it
95
+ reads near zero. The honest latency is on the model call, from the SDK's
96
+ `duration_api_ms`. Fixing it properly means letting `end_turn` accept a
97
+ measured duration, which is a core-SDK change and not this connector's to make.
@@ -0,0 +1,232 @@
1
+ """
2
+ ChatSee connector for the Claude Agent SDK.
3
+
4
+ from chatsee_claude_agent_sdk import instrument_claude_agent_sdk
5
+
6
+ chatsee = instrument_claude_agent_sdk(agent_id="...", tenant_id="...")
7
+
8
+ async for message in chatsee.query(prompt="where is my order", options=opts):
9
+ ...
10
+
11
+ Unlike the Messages API connector, there is no client object to patch — the
12
+ Agent SDK's entry point is a module-level ``query()``. So this returns a handle
13
+ whose ``.query`` is a drop-in for it: every message still reaches the caller,
14
+ unchanged and in order, while the turn is assembled alongside and sent when the
15
+ terminal ``ResultMessage`` arrives.
16
+
17
+ ``claude_agent_sdk.query`` is also replaced, so code that calls it through the
18
+ module is captured without changing a line. Code that did
19
+ ``from claude_agent_sdk import query`` already holds its own reference to the
20
+ original function and cannot be reached that way — use the handle.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ from typing import Any, AsyncIterator, Optional
26
+
27
+ from ._capture import (
28
+ FRAMEWORK,
29
+ TurnAccumulator,
30
+ UsageLedger,
31
+ model_calls_from,
32
+ system_prompt_of,
33
+ )
34
+
35
+ __version__ = "0.1.0"
36
+ __all__ = ["instrument_claude_agent_sdk", "ChatseeClaudeAgentSDK", "__version__"]
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ _MARKER = "_chatsee_instrumented"
41
+
42
+
43
+ def _framework_version() -> str:
44
+ try:
45
+ import claude_agent_sdk
46
+
47
+ return getattr(claude_agent_sdk, "__version__", "unknown")
48
+ except Exception:
49
+ return "unknown"
50
+
51
+
52
+ def _attr(obj: Any, name: str, default: Any = None) -> Any:
53
+ if isinstance(obj, dict):
54
+ return obj.get(name, default)
55
+ return getattr(obj, name, default)
56
+
57
+
58
+ def _is_result(message: Any) -> bool:
59
+ """The terminal message, identified by shape rather than by import.
60
+
61
+ Importing ResultMessage would make the connector unimportable without the
62
+ SDK installed, which the offline tests rely on not being the case.
63
+ """
64
+ return type(message).__name__ == "ResultMessage" or (
65
+ _attr(message, "subtype") is not None and _attr(message, "total_cost_usd") is not None
66
+ )
67
+
68
+
69
+ def _kind(message: Any) -> str:
70
+ return type(message).__name__
71
+
72
+
73
+ class ChatseeClaudeAgentSDK:
74
+ """Handle returned by :func:`instrument_claude_agent_sdk`."""
75
+
76
+ def __init__(self, tracker: Any, query_fn: Any, user_id: Optional[str] = None) -> None:
77
+ self._tracker = tracker
78
+ self._query = query_fn
79
+ self._user_id = user_id
80
+ # Per-session running totals, so cumulative figures become per-turn.
81
+ self._ledger = UsageLedger()
82
+
83
+ # -- the drop-in -------------------------------------------------------
84
+
85
+ def query(self, *args: Any, **kwargs: Any) -> AsyncIterator[Any]:
86
+ """A drop-in for the SDK's ``query()``."""
87
+ return self._stream(*args, **kwargs)
88
+
89
+ async def _stream(self, *args: Any, **kwargs: Any) -> AsyncIterator[Any]:
90
+ prompt = kwargs.get("prompt")
91
+ if prompt is None and args:
92
+ prompt = args[0]
93
+ options = kwargs.get("options")
94
+
95
+ # One accumulator per call, never shared, so concurrent queries cannot
96
+ # contaminate each other.
97
+ turn = TurnAccumulator()
98
+
99
+ async for message in self._query(*args, **kwargs):
100
+ try:
101
+ kind = _kind(message)
102
+ if kind == "AssistantMessage":
103
+ turn.observe_assistant(message)
104
+ elif kind == "UserMessage":
105
+ turn.observe_user(message)
106
+ elif _is_result(message):
107
+ self._emit(prompt, options, turn, message)
108
+ except Exception as exc: # noqa: BLE001 - never break the agent
109
+ logger.error("chatsee-claude-agent-sdk capture failed: %s", exc,
110
+ exc_info=True)
111
+ yield message # forwarded unchanged, in order
112
+
113
+ # -- emitting ----------------------------------------------------------
114
+
115
+ def _metadata(self) -> dict:
116
+ return {
117
+ "framework": FRAMEWORK,
118
+ "framework_version": _framework_version(),
119
+ "connector_version": __version__,
120
+ }
121
+
122
+ def _emit(self, prompt: Any, options: Any, turn: TurnAccumulator,
123
+ result_message: Any) -> None:
124
+ """All four tracker calls, together, when the turn is known to be over.
125
+
126
+ Emitted in one go rather than opening the turn when the stream starts:
127
+ the tracker holds a single turn at a time, so a start_turn left open
128
+ across an await would be clobbered by any concurrent query.
129
+ """
130
+ tracker = self._tracker
131
+ answer = _attr(result_message, "result") or ""
132
+
133
+ tracker.start_turn(
134
+ prompt if isinstance(prompt, str) else "",
135
+ metadata=self._metadata(),
136
+ system_prompt=system_prompt_of(options),
137
+ user_id=self._user_id,
138
+ )
139
+
140
+ for call in turn.resolved_tool_calls():
141
+ tracker.log_tool_call(call["tool_name"], call["arguments"],
142
+ result=call["result"])
143
+
144
+ # One entry per model the turn used. The SDK states a real dollar cost,
145
+ # so it is passed through rather than estimated -- nothing here invents
146
+ # a price. duration_api_ms is the time actually spent in the API, which
147
+ # is the honest per-call latency.
148
+ api_ms = _attr(result_message, "duration_api_ms")
149
+ session_id = _attr(result_message, "session_id")
150
+ calls, cumulative = model_calls_from(result_message)
151
+
152
+ # model_usage and total_cost_usd are running totals for the query()
153
+ # call, not this turn's share. One turn per call makes them the same
154
+ # number, which is why it never shows in the ordinary case; a
155
+ # streaming-input or resumed session runs several turns through one
156
+ # total, and recording it raw makes turn three report turns one, two
157
+ # and three.
158
+ if cumulative:
159
+ calls = self._ledger.per_turn_models(session_id, calls)
160
+ total_cost = None # already carried per model
161
+ else:
162
+ # The aggregate usage is per-turn, but the cost beside it is not.
163
+ total_cost = self._ledger.per_turn_cost(
164
+ session_id, _attr(result_message, "total_cost_usd"))
165
+
166
+ for entry in calls:
167
+ tracker.log_model_call(
168
+ model=entry.get("model") or turn.model,
169
+ provider="anthropic",
170
+ prompt_tokens=entry.get("prompt_tokens"),
171
+ completion_tokens=entry.get("completion_tokens"),
172
+ cached_tokens=entry.get("cached_tokens"),
173
+ # A per-model cost when the SDK broke it down, otherwise the
174
+ # turn's cost on the single entry. Never divided up: a split
175
+ # we invented would look like measurement.
176
+ cost_usd=entry.get("cost_usd") if entry.get("cost_usd") is not None
177
+ else (total_cost if len(calls) == 1 else None),
178
+ duration_ms=api_ms if len(calls) == 1 else None,
179
+ )
180
+
181
+ if _attr(result_message, "is_error"):
182
+ tracker.log_exception(
183
+ "claude-agent-sdk reported subtype=%s" % _attr(result_message, "subtype")
184
+ )
185
+
186
+ tracker.end_turn(answer)
187
+
188
+
189
+ def instrument_claude_agent_sdk(
190
+ agent_id: Optional[str] = None,
191
+ tenant_id: Optional[str] = None,
192
+ user_id: Optional[str] = None,
193
+ session_id: Optional[str] = None,
194
+ api_base_url: Optional[str] = None,
195
+ _tracker: Any = None,
196
+ _query: Any = None,
197
+ ) -> ChatseeClaudeAgentSDK:
198
+ """Capture every turn the Claude Agent SDK runs.
199
+
200
+ ``_tracker`` and ``_query`` are private and exist so the contract tests can
201
+ inject a fake tracker and a stub SDK. Customers never pass them.
202
+ """
203
+ if _tracker is None:
204
+ from chatsee import ChatseeTracker
205
+
206
+ if not agent_id or not tenant_id:
207
+ raise ValueError("agent_id and tenant_id are required.")
208
+ _tracker = ChatseeTracker(
209
+ agent_id=agent_id,
210
+ tenant_id=tenant_id,
211
+ user_id=user_id,
212
+ session_id=session_id,
213
+ api_base_url=api_base_url,
214
+ )
215
+
216
+ module = None
217
+ if _query is None:
218
+ import claude_agent_sdk as module
219
+
220
+ _query = getattr(module, _MARKER + "_original", None) or module.query
221
+
222
+ handle = ChatseeClaudeAgentSDK(_tracker, _query, user_id)
223
+
224
+ # Also replace the module-level query, so callers using
225
+ # `claude_agent_sdk.query(...)` need no change at all. Guarded, because
226
+ # instrumenting twice must not wrap twice.
227
+ if module is not None and not getattr(module, _MARKER, False):
228
+ setattr(module, _MARKER + "_original", _query)
229
+ module.query = handle.query
230
+ setattr(module, _MARKER, True)
231
+
232
+ return handle