doppel-sdk 0.1.0__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.
doppel_sdk/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """Official Doppel Python SDK.
2
+
3
+ Wrap your existing provider client and every LLM call is captured and forwarded
4
+ to Doppel — no change to your call sites.
5
+
6
+ from doppel_sdk import DoppelClient
7
+ from openai import OpenAI
8
+
9
+ doppel = DoppelClient(shadow_model="gpt-4o-mini") # reads DOPPEL_API_KEY
10
+ client = doppel.wrap_openai(OpenAI())
11
+ client.chat.completions.create(model="gpt-4o", messages=[...])
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from ._config import ImpactConfig
17
+ from ._transport import flush
18
+ from .client import DoppelClient
19
+ from .interceptors.anthropic import wrap_anthropic
20
+ from .interceptors.google import wrap_google
21
+ from .interceptors.openai import wrap_openai
22
+ from .interceptors.openrouter import wrap_openrouter
23
+
24
+ __all__ = [
25
+ "DoppelClient",
26
+ "ImpactConfig",
27
+ "wrap_openai",
28
+ "wrap_openrouter",
29
+ "wrap_anthropic",
30
+ "wrap_google",
31
+ "flush",
32
+ ]
33
+
34
+ __version__ = "0.1.0"
doppel_sdk/_config.py ADDED
@@ -0,0 +1,61 @@
1
+ """Configuration resolution for the Doppel SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from typing import Optional
8
+
9
+ DEFAULT_SERVER_URL = "https://api.doppel.in"
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ImpactConfig:
14
+ """Fully-resolved configuration consumed by the interceptors."""
15
+
16
+ api_key: str
17
+ server_url: str
18
+ shadow_model: Optional[str]
19
+ debug: bool
20
+
21
+
22
+ def _read_env(name: str) -> Optional[str]:
23
+ value = os.environ.get(name)
24
+ return value or None
25
+
26
+
27
+ def _resolve_debug(option_debug: Optional[bool]) -> bool:
28
+ if option_debug is not None:
29
+ return option_debug
30
+ env = (_read_env("DOPPEL_DEBUG") or "").lower()
31
+ return env in ("1", "true")
32
+
33
+
34
+ def resolve_config(
35
+ api_key: Optional[str] = None,
36
+ server_url: Optional[str] = None,
37
+ shadow_model: Optional[str] = None,
38
+ debug: Optional[bool] = None,
39
+ ) -> ImpactConfig:
40
+ """Resolve user-supplied options together with environment variables.
41
+
42
+ Resolution order (highest priority first):
43
+ - api_key -> argument > DOPPEL_API_KEY
44
+ - server_url -> argument > DOPPEL_SERVER_URL > https://api.doppel.in
45
+ - shadow_model -> argument (optional; chosen in the dashboard when omitted)
46
+ - debug -> argument > DOPPEL_DEBUG ('1'/'true')
47
+ """
48
+ key = api_key or _read_env("DOPPEL_API_KEY")
49
+ if not key:
50
+ raise ValueError(
51
+ "[DoppelClient] Missing API key. Set the DOPPEL_API_KEY environment "
52
+ "variable (recommended) or pass api_key=... to DoppelClient()."
53
+ )
54
+
55
+ url = server_url or _read_env("DOPPEL_SERVER_URL") or DEFAULT_SERVER_URL
56
+ return ImpactConfig(
57
+ api_key=key,
58
+ server_url=url,
59
+ shadow_model=shadow_model,
60
+ debug=_resolve_debug(debug),
61
+ )
@@ -0,0 +1,81 @@
1
+ """Fire-and-forget run delivery.
2
+
3
+ Runs are POSTed on a background thread pool so capture never blocks (or fails)
4
+ the caller's request path. ``flush`` awaits in-flight deliveries before a
5
+ short-lived process exits. Uses only the standard library — no dependencies.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import urllib.error
13
+ import urllib.request
14
+ from concurrent.futures import Future, ThreadPoolExecutor
15
+ from typing import Any, Dict, Optional, Set
16
+
17
+ from ._config import ImpactConfig
18
+
19
+ logger = logging.getLogger("doppel_sdk")
20
+
21
+ _SEND_TIMEOUT_SECONDS = 10
22
+ _executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="doppel-sdk")
23
+ _inflight: Set["Future[None]"] = set()
24
+
25
+
26
+ def send_run(payload: Dict[str, Any], config: ImpactConfig) -> None:
27
+ """Queue a run for fire-and-forget delivery. Never raises."""
28
+ # Drop None values so the JSON matches the JS payloads (omitted, not null).
29
+ body = {k: v for k, v in payload.items() if v is not None}
30
+ try:
31
+ future = _executor.submit(_deliver, body, config)
32
+ except Exception as err: # executor shut down, etc. — never raise into caller
33
+ logger.warning("[doppel-sdk] Failed to queue run (non-blocking): %s", err)
34
+ return
35
+ _inflight.add(future)
36
+ future.add_done_callback(_inflight.discard)
37
+
38
+
39
+ def _deliver(payload: Dict[str, Any], config: ImpactConfig) -> None:
40
+ url = config.server_url.rstrip("/") + "/runs"
41
+ if config.debug:
42
+ logger.info("[doppel-sdk] Sending run to: %s", url)
43
+ try:
44
+ data = json.dumps(payload, default=_to_jsonable).encode("utf-8")
45
+ request = urllib.request.Request(
46
+ url,
47
+ data=data,
48
+ method="POST",
49
+ headers={"Content-Type": "application/json", "x-api-key": config.api_key},
50
+ )
51
+ with urllib.request.urlopen(request, timeout=_SEND_TIMEOUT_SECONDS) as resp:
52
+ status = getattr(resp, "status", None) or resp.getcode()
53
+ if status and status >= 400:
54
+ logger.warning("[doppel-sdk] Server returned non-OK: %s", status)
55
+ elif config.debug:
56
+ logger.info("[doppel-sdk] Run sent successfully: %s", status)
57
+ except urllib.error.HTTPError as err:
58
+ logger.warning("[doppel-sdk] Server returned non-OK: %s %s", err.code, err.reason)
59
+ except Exception as err: # network/serialisation — never raise into caller
60
+ logger.warning("[doppel-sdk] Failed to send run (non-blocking): %s", err)
61
+
62
+
63
+ def _to_jsonable(obj: Any) -> Any:
64
+ """Best-effort fallback for provider message objects that aren't plain dicts."""
65
+ for attr in ("model_dump", "dict", "to_dict"):
66
+ method = getattr(obj, attr, None)
67
+ if callable(method):
68
+ try:
69
+ return method()
70
+ except Exception:
71
+ pass
72
+ return str(obj)
73
+
74
+
75
+ def flush(timeout: Optional[float] = None) -> None:
76
+ """Block until all in-flight run deliveries settle. Never raises."""
77
+ for future in list(_inflight):
78
+ try:
79
+ future.result(timeout)
80
+ except Exception:
81
+ pass
doppel_sdk/_utils.py ADDED
@@ -0,0 +1,40 @@
1
+ """Small shared helpers for the interceptors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Optional
8
+
9
+ logger = logging.getLogger("doppel_sdk")
10
+
11
+
12
+ def attr(obj: Any, name: str) -> Any:
13
+ """Read a field from a provider object whether it's a mapping or an object.
14
+
15
+ Real provider SDKs return pydantic models (attribute access); tests and some
16
+ runtimes use plain dicts. This tolerates both, returning None when absent.
17
+ """
18
+ if obj is None:
19
+ return None
20
+ if isinstance(obj, dict):
21
+ return obj.get(name)
22
+ return getattr(obj, name, None)
23
+
24
+
25
+ def now_iso() -> str:
26
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
27
+
28
+
29
+ def warn_capture(provider: str, err: Exception) -> None:
30
+ logger.warning(
31
+ "[doppel-sdk] Failed to capture %s run (non-blocking): %s", provider, err
32
+ )
33
+
34
+
35
+ def is_sync_stream(obj: Any) -> bool:
36
+ return hasattr(obj, "__iter__") and not isinstance(obj, (str, bytes, list, tuple, dict))
37
+
38
+
39
+ def is_async_stream(obj: Any) -> bool:
40
+ return hasattr(obj, "__aiter__")
doppel_sdk/client.py ADDED
@@ -0,0 +1,53 @@
1
+ """The DoppelClient facade."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ from ._config import ImpactConfig, resolve_config
8
+ from ._transport import flush as _flush
9
+ from .interceptors.anthropic import wrap_anthropic
10
+ from .interceptors.google import wrap_google
11
+ from .interceptors.openai import wrap_openai
12
+ from .interceptors.openrouter import wrap_openrouter
13
+
14
+
15
+ class DoppelClient:
16
+ """Wraps provider clients so every LLM call is captured and sent to Doppel.
17
+
18
+ Configuration is read from arguments or the ``DOPPEL_API_KEY`` /
19
+ ``DOPPEL_SERVER_URL`` / ``DOPPEL_DEBUG`` environment variables.
20
+
21
+ doppel = DoppelClient(shadow_model="gpt-4o-mini")
22
+ client = doppel.wrap_openai(OpenAI())
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ api_key: Optional[str] = None,
28
+ shadow_model: Optional[str] = None,
29
+ server_url: Optional[str] = None,
30
+ debug: Optional[bool] = None,
31
+ ) -> None:
32
+ self._config: ImpactConfig = resolve_config(
33
+ api_key=api_key,
34
+ server_url=server_url,
35
+ shadow_model=shadow_model,
36
+ debug=debug,
37
+ )
38
+
39
+ def wrap_openai(self, client: Any) -> Any:
40
+ return wrap_openai(client, self._config)
41
+
42
+ def wrap_openrouter(self, client: Any) -> Any:
43
+ return wrap_openrouter(client, self._config)
44
+
45
+ def wrap_anthropic(self, client: Any) -> Any:
46
+ return wrap_anthropic(client, self._config)
47
+
48
+ def wrap_google(self, client: Any) -> Any:
49
+ return wrap_google(client, self._config)
50
+
51
+ def flush(self, timeout: Optional[float] = None) -> None:
52
+ """Block until all pending run deliveries settle. Never raises."""
53
+ _flush(timeout)
@@ -0,0 +1 @@
1
+ """Provider interceptors for the Doppel SDK."""
@@ -0,0 +1,157 @@
1
+ """Shared engine for OpenAI-wire-compatible providers (OpenAI, OpenRouter).
2
+
3
+ Handles sync and async clients (detected by whether ``create`` returns an
4
+ awaitable) and both streaming and non-streaming calls. ``wrap_openai`` and
5
+ ``wrap_openrouter`` are thin presets over this.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import inspect
11
+ import time
12
+ from typing import Any, Dict, Optional
13
+
14
+ from .._config import ImpactConfig
15
+ from .._transport import send_run
16
+ from .._utils import attr, is_async_stream, is_sync_stream, now_iso, warn_capture
17
+ from ._stream import capture_async_stream, capture_sync_stream
18
+
19
+ _MARKER = "_doppel_wrapped_openai_compat"
20
+
21
+
22
+ def _vendor_from_model(model: Optional[str]) -> Optional[str]:
23
+ """OpenRouter model ids are namespaced `<author>/<model>` — return the author."""
24
+ if not model or "/" not in model:
25
+ return None
26
+ return model.split("/")[0].lower()
27
+
28
+
29
+ def _chunk_content(chunk: Any) -> Optional[str]:
30
+ choices = attr(chunk, "choices")
31
+ if not choices:
32
+ return None
33
+ return attr(attr(choices[0], "delta"), "content")
34
+
35
+
36
+ def _response_answer(response: Any) -> str:
37
+ choices = attr(response, "choices")
38
+ if not choices:
39
+ return ""
40
+ return attr(attr(choices[0], "message"), "content") or ""
41
+
42
+
43
+ class _Spec:
44
+ def __init__(self, provider: str, derive_vendor: bool, capture_cost: bool):
45
+ self.provider = provider
46
+ self.derive_vendor = derive_vendor
47
+ self.capture_cost = capture_cost
48
+
49
+
50
+ def wrap_openai_compatible(
51
+ client: Any,
52
+ config: ImpactConfig,
53
+ *,
54
+ provider: str,
55
+ derive_vendor: bool = False,
56
+ capture_cost: bool = False,
57
+ ) -> Any:
58
+ spec = _Spec(provider, derive_vendor, capture_cost)
59
+
60
+ try:
61
+ completions = client.chat.completions
62
+ original = completions.create
63
+ except AttributeError:
64
+ return client # not an OpenAI-shaped client
65
+
66
+ if getattr(original, _MARKER, False):
67
+ return client
68
+
69
+ def emit(kwargs: Dict[str, Any], meta: Dict[str, Any], answer: str, usage: Any, model: Optional[str], start: float) -> None:
70
+ try:
71
+ payload: Dict[str, Any] = {
72
+ "timestamp": now_iso(),
73
+ "sessionId": meta.get("sessionId"),
74
+ "model": model or kwargs.get("model") or "",
75
+ "shadowModel": config.shadow_model,
76
+ "temperature": kwargs.get("temperature"),
77
+ "messages": kwargs.get("messages"),
78
+ "pdfCharsSent": meta.get("pdfCharsSent"),
79
+ "question": meta.get("question"),
80
+ "answer": answer,
81
+ "promptTokens": attr(usage, "prompt_tokens") or 0,
82
+ "completionTokens": attr(usage, "completion_tokens") or 0,
83
+ "totalTokens": attr(usage, "total_tokens") or 0,
84
+ "durationMs": int((time.time() - start) * 1000),
85
+ }
86
+ if spec.provider == "openrouter":
87
+ payload["provider"] = "openrouter"
88
+ if spec.derive_vendor:
89
+ vendor = _vendor_from_model(payload["model"])
90
+ if vendor:
91
+ payload["vendor"] = vendor
92
+ if spec.capture_cost:
93
+ cost = attr(usage, "cost")
94
+ if cost is not None:
95
+ payload["cost"] = cost
96
+ else:
97
+ payload["type"] = "llm-response"
98
+ send_run(payload, config)
99
+ except Exception as err:
100
+ warn_capture(spec.provider, err)
101
+
102
+ def stream_callbacks(kwargs: Dict[str, Any], meta: Dict[str, Any], start: float):
103
+ state: Dict[str, Any] = {"answer": "", "model": kwargs.get("model"), "usage": None}
104
+
105
+ def on_chunk(chunk: Any) -> None:
106
+ content = _chunk_content(chunk)
107
+ if isinstance(content, str):
108
+ state["answer"] += content
109
+ usage = attr(chunk, "usage")
110
+ if usage is not None:
111
+ state["usage"] = usage
112
+ model = attr(chunk, "model")
113
+ if model:
114
+ state["model"] = model
115
+
116
+ def on_done() -> None:
117
+ emit(kwargs, meta, state["answer"], state["usage"], state["model"], start)
118
+
119
+ return on_chunk, on_done
120
+
121
+ def patched(*args: Any, **kwargs: Any) -> Any:
122
+ meta = kwargs.pop("_impact_meta", None) or {}
123
+ is_streaming = bool(kwargs.get("stream"))
124
+
125
+ # Streams only report usage when stream_options.include_usage is set.
126
+ if is_streaming and "stream_options" not in kwargs:
127
+ kwargs = dict(kwargs)
128
+ kwargs["stream_options"] = {"include_usage": True}
129
+
130
+ start = time.time()
131
+ result = original(*args, **kwargs)
132
+
133
+ if inspect.isawaitable(result):
134
+ async def run_async() -> Any:
135
+ resolved = await result
136
+ if is_streaming:
137
+ if not is_async_stream(resolved):
138
+ return resolved
139
+ on_chunk, on_done = stream_callbacks(kwargs, meta, start)
140
+ return capture_async_stream(resolved, on_chunk, on_done)
141
+ emit(kwargs, meta, _response_answer(resolved), attr(resolved, "usage"), kwargs.get("model"), start)
142
+ return resolved
143
+
144
+ return run_async()
145
+
146
+ if is_streaming:
147
+ if not is_sync_stream(result):
148
+ return result
149
+ on_chunk, on_done = stream_callbacks(kwargs, meta, start)
150
+ return capture_sync_stream(result, on_chunk, on_done)
151
+
152
+ emit(kwargs, meta, _response_answer(result), attr(result, "usage"), kwargs.get("model"), start)
153
+ return result
154
+
155
+ setattr(patched, _MARKER, True)
156
+ completions.create = patched
157
+ return client
@@ -0,0 +1,100 @@
1
+ """Streaming capture proxies.
2
+
3
+ A provider stream is single-consumption, so we tee it: each chunk reaches both
4
+ the caller and an accumulator, and a capture callback fires once iteration ends
5
+ (or the caller abandons it). The proxy delegates every other attribute/method to
6
+ the underlying stream so context-manager and helper methods keep working.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, AsyncIterator, Callable, Iterator
12
+
13
+
14
+ class _BaseStreamProxy:
15
+ def __init__(self, stream: Any, on_chunk: Callable[[Any], None], on_done: Callable[[], None]):
16
+ self._stream = stream
17
+ self._on_chunk = on_chunk
18
+ self._on_done = on_done
19
+ self._finished = False
20
+
21
+ def _finish(self) -> None:
22
+ if self._finished:
23
+ return
24
+ self._finished = True
25
+ try:
26
+ self._on_done()
27
+ except Exception: # capture must never throw into the caller
28
+ pass
29
+
30
+ def _feed(self, chunk: Any) -> None:
31
+ try:
32
+ self._on_chunk(chunk)
33
+ except Exception: # a bad chunk must not break iteration
34
+ pass
35
+
36
+ # Delegate everything else (e.g. `.response`, `.close()`) to the real stream.
37
+ def __getattr__(self, name: str) -> Any:
38
+ return getattr(self._stream, name)
39
+
40
+
41
+ class SyncStreamProxy(_BaseStreamProxy):
42
+ def __iter__(self) -> Iterator[Any]:
43
+ return self._iterate()
44
+
45
+ def _iterate(self) -> Iterator[Any]:
46
+ try:
47
+ for chunk in self._stream:
48
+ self._feed(chunk)
49
+ yield chunk
50
+ finally:
51
+ self._finish()
52
+
53
+ def __enter__(self) -> "SyncStreamProxy":
54
+ enter = getattr(self._stream, "__enter__", None)
55
+ if callable(enter):
56
+ enter()
57
+ return self
58
+
59
+ def __exit__(self, *exc: Any) -> Any:
60
+ result = None
61
+ exit_ = getattr(self._stream, "__exit__", None)
62
+ if callable(exit_):
63
+ result = exit_(*exc)
64
+ self._finish()
65
+ return result
66
+
67
+
68
+ class AsyncStreamProxy(_BaseStreamProxy):
69
+ def __aiter__(self) -> AsyncIterator[Any]:
70
+ return self._aiterate()
71
+
72
+ async def _aiterate(self) -> AsyncIterator[Any]:
73
+ try:
74
+ async for chunk in self._stream:
75
+ self._feed(chunk)
76
+ yield chunk
77
+ finally:
78
+ self._finish()
79
+
80
+ async def __aenter__(self) -> "AsyncStreamProxy":
81
+ aenter = getattr(self._stream, "__aenter__", None)
82
+ if aenter is not None:
83
+ await aenter()
84
+ return self
85
+
86
+ async def __aexit__(self, *exc: Any) -> Any:
87
+ result = None
88
+ aexit = getattr(self._stream, "__aexit__", None)
89
+ if aexit is not None:
90
+ result = await aexit(*exc)
91
+ self._finish()
92
+ return result
93
+
94
+
95
+ def capture_sync_stream(stream: Any, on_chunk: Callable[[Any], None], on_done: Callable[[], None]) -> SyncStreamProxy:
96
+ return SyncStreamProxy(stream, on_chunk, on_done)
97
+
98
+
99
+ def capture_async_stream(stream: Any, on_chunk: Callable[[Any], None], on_done: Callable[[], None]) -> AsyncStreamProxy:
100
+ return AsyncStreamProxy(stream, on_chunk, on_done)
@@ -0,0 +1,127 @@
1
+ """Anthropic interceptor (``messages.create``), sync + async, streaming-aware."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import time
7
+ from typing import Any, Dict, Optional
8
+
9
+ from .._config import ImpactConfig
10
+ from .._transport import send_run
11
+ from .._utils import attr, is_async_stream, is_sync_stream, warn_capture
12
+ from ._stream import capture_async_stream, capture_sync_stream
13
+
14
+ _MARKER = "_doppel_wrapped_anthropic"
15
+
16
+
17
+ def _content_to_text(content: Any) -> str:
18
+ if isinstance(content, str):
19
+ return content
20
+ if isinstance(content, list):
21
+ return "".join(b if isinstance(b, str) else (attr(b, "text") or "") for b in content)
22
+ return ""
23
+
24
+
25
+ def _prompt_from_messages(messages: Any) -> str:
26
+ if not messages:
27
+ return ""
28
+ return "\n".join(_content_to_text(attr(m, "content")) for m in messages)
29
+
30
+
31
+ def _response_output(response: Any) -> str:
32
+ blocks = attr(response, "content") or []
33
+ return "".join(attr(b, "text") or "" for b in blocks if attr(b, "type") == "text")
34
+
35
+
36
+ def wrap_anthropic(client: Any, config: ImpactConfig) -> Any:
37
+ """Intercept an ``Anthropic``/``AsyncAnthropic`` client's ``messages.create``."""
38
+ try:
39
+ messages_ns = client.messages
40
+ original = messages_ns.create
41
+ except AttributeError:
42
+ return client
43
+ if getattr(original, _MARKER, False):
44
+ return client
45
+
46
+ def emit(prompt: str, output: str, prompt_tokens: Optional[int], completion_tokens: Optional[int], model: Optional[str], kwargs: Dict[str, Any], start: float) -> None:
47
+ try:
48
+ total = (prompt_tokens or 0) + (completion_tokens or 0)
49
+ send_run(
50
+ {
51
+ "prompt": prompt,
52
+ "model": model or kwargs.get("model") or "",
53
+ "shadowModel": config.shadow_model,
54
+ "promptTokens": prompt_tokens or 0,
55
+ "completionTokens": completion_tokens or 0,
56
+ "totalTokens": total,
57
+ "primaryResponse": {
58
+ "output": output,
59
+ "latencyMs": int((time.time() - start) * 1000),
60
+ "tokens": total,
61
+ },
62
+ },
63
+ config,
64
+ )
65
+ except Exception as err:
66
+ warn_capture("Anthropic", err)
67
+
68
+ def stream_callbacks(prompt: str, kwargs: Dict[str, Any], start: float):
69
+ state: Dict[str, Any] = {"output": "", "prompt_tokens": 0, "completion_tokens": 0, "model": kwargs.get("model")}
70
+
71
+ def on_chunk(event: Any) -> None:
72
+ etype = attr(event, "type")
73
+ if etype == "message_start":
74
+ message = attr(event, "message")
75
+ input_tokens = attr(attr(message, "usage"), "input_tokens")
76
+ if input_tokens is not None:
77
+ state["prompt_tokens"] = input_tokens
78
+ model = attr(message, "model")
79
+ if model:
80
+ state["model"] = model
81
+ elif etype == "content_block_delta":
82
+ text = attr(attr(event, "delta"), "text")
83
+ if isinstance(text, str):
84
+ state["output"] += text
85
+ elif etype == "message_delta":
86
+ output_tokens = attr(attr(event, "usage"), "output_tokens")
87
+ if output_tokens is not None:
88
+ state["completion_tokens"] = output_tokens
89
+
90
+ def on_done() -> None:
91
+ emit(prompt, state["output"], state["prompt_tokens"], state["completion_tokens"], state["model"], kwargs, start)
92
+
93
+ return on_chunk, on_done
94
+
95
+ def patched(*args: Any, **kwargs: Any) -> Any:
96
+ is_streaming = bool(kwargs.get("stream"))
97
+ prompt = _prompt_from_messages(kwargs.get("messages"))
98
+ start = time.time()
99
+ result = original(*args, **kwargs)
100
+
101
+ if inspect.isawaitable(result):
102
+ async def run_async() -> Any:
103
+ resolved = await result
104
+ if is_streaming:
105
+ if not is_async_stream(resolved):
106
+ return resolved
107
+ on_chunk, on_done = stream_callbacks(prompt, kwargs, start)
108
+ return capture_async_stream(resolved, on_chunk, on_done)
109
+ usage = attr(resolved, "usage")
110
+ emit(prompt, _response_output(resolved), attr(usage, "input_tokens"), attr(usage, "output_tokens"), attr(resolved, "model"), kwargs, start)
111
+ return resolved
112
+
113
+ return run_async()
114
+
115
+ if is_streaming:
116
+ if not is_sync_stream(result):
117
+ return result
118
+ on_chunk, on_done = stream_callbacks(prompt, kwargs, start)
119
+ return capture_sync_stream(result, on_chunk, on_done)
120
+
121
+ usage = attr(result, "usage")
122
+ emit(prompt, _response_output(result), attr(usage, "input_tokens"), attr(usage, "output_tokens"), attr(result, "model"), kwargs, start)
123
+ return result
124
+
125
+ setattr(patched, _MARKER, True)
126
+ messages_ns.create = patched
127
+ return client
@@ -0,0 +1,196 @@
1
+ """Google Gemini interceptor.
2
+
3
+ Supports the new ``google-genai`` SDK (sync ``client.models`` + async
4
+ ``client.aio.models``, with ``generate_content`` / ``generate_content_stream``)
5
+ and the legacy ``google-generativeai`` SDK (a ``GenerativeModel`` whose
6
+ ``generate_content`` takes ``stream=`` and which also exposes
7
+ ``generate_content_async``).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import inspect
13
+ import time
14
+ from typing import Any, Optional
15
+
16
+ from .._config import ImpactConfig
17
+ from .._transport import send_run
18
+ from .._utils import attr, is_async_stream, is_sync_stream, now_iso, warn_capture
19
+ from ._stream import capture_async_stream, capture_sync_stream
20
+
21
+ _MARKER = "_doppel_wrapped_google"
22
+
23
+
24
+ def _contents_to_text(contents: Any) -> str:
25
+ if contents is None:
26
+ return ""
27
+ if isinstance(contents, str):
28
+ return contents
29
+ if isinstance(contents, list):
30
+ return "\n".join(_contents_to_text(c) for c in contents)
31
+ text = attr(contents, "text")
32
+ if isinstance(text, str):
33
+ return text
34
+ parts = attr(contents, "parts")
35
+ if parts is not None:
36
+ return _contents_to_text(parts)
37
+ nested = attr(contents, "content")
38
+ if nested is not None:
39
+ return _contents_to_text(nested)
40
+ return ""
41
+
42
+
43
+ def _text(obj: Any) -> str:
44
+ """`.text` is a property that can raise on non-text chunks — guard it."""
45
+ try:
46
+ value = attr(obj, "text")
47
+ return value if isinstance(value, str) else ""
48
+ except Exception:
49
+ return ""
50
+
51
+
52
+ def _emit(config: ImpactConfig, contents: Any, answer: str, usage: Any, model: Optional[str], start: float) -> None:
53
+ try:
54
+ prompt_tokens = attr(usage, "prompt_token_count") or 0
55
+ completion_tokens = attr(usage, "candidates_token_count") or 0
56
+ total_tokens = attr(usage, "total_token_count")
57
+ send_run(
58
+ {
59
+ "provider": "google",
60
+ "timestamp": now_iso(),
61
+ "model": model or "",
62
+ "shadowModel": config.shadow_model,
63
+ "prompt": _contents_to_text(contents),
64
+ "answer": answer,
65
+ "promptTokens": prompt_tokens,
66
+ "completionTokens": completion_tokens,
67
+ "totalTokens": total_tokens if total_tokens is not None else prompt_tokens + completion_tokens,
68
+ "durationMs": int((time.time() - start) * 1000),
69
+ },
70
+ config,
71
+ )
72
+ except Exception as err:
73
+ warn_capture("Google", err)
74
+
75
+
76
+ def _stream_callbacks(config: ImpactConfig, contents: Any, model: Optional[str], start: float):
77
+ state = {"answer": "", "usage": None, "model": model}
78
+
79
+ def on_chunk(chunk: Any) -> None:
80
+ state["answer"] += _text(chunk)
81
+ usage = attr(chunk, "usage_metadata")
82
+ if usage is not None:
83
+ state["usage"] = usage
84
+
85
+ def on_done() -> None:
86
+ _emit(config, contents, state["answer"], state["usage"], state["model"], start)
87
+
88
+ return on_chunk, on_done
89
+
90
+
91
+ def _patch(namespace: Any, attr_name: str, builder: Any) -> None:
92
+ original = getattr(namespace, attr_name, None)
93
+ if not callable(original) or getattr(original, _MARKER, False):
94
+ return
95
+ patched = builder(original)
96
+ setattr(patched, _MARKER, True)
97
+ setattr(namespace, attr_name, patched)
98
+
99
+
100
+ def _wrap_genai_namespace(ns: Any, config: ImpactConfig) -> None:
101
+ def unary(original):
102
+ def patched(*args, **kwargs):
103
+ start = time.time()
104
+ contents, model = kwargs.get("contents"), kwargs.get("model")
105
+ result = original(*args, **kwargs)
106
+ if inspect.isawaitable(result):
107
+ async def run():
108
+ resolved = await result
109
+ _emit(config, contents, _text(resolved), attr(resolved, "usage_metadata"), model, start)
110
+ return resolved
111
+ return run()
112
+ _emit(config, contents, _text(result), attr(result, "usage_metadata"), model, start)
113
+ return result
114
+ return patched
115
+
116
+ def streaming(original):
117
+ def patched(*args, **kwargs):
118
+ start = time.time()
119
+ contents, model = kwargs.get("contents"), kwargs.get("model")
120
+ result = original(*args, **kwargs)
121
+ if inspect.isawaitable(result):
122
+ async def run():
123
+ resolved = await result
124
+ if not is_async_stream(resolved):
125
+ return resolved
126
+ on_chunk, on_done = _stream_callbacks(config, contents, model, start)
127
+ return capture_async_stream(resolved, on_chunk, on_done)
128
+ return run()
129
+ if not is_sync_stream(result):
130
+ return result
131
+ on_chunk, on_done = _stream_callbacks(config, contents, model, start)
132
+ return capture_sync_stream(result, on_chunk, on_done)
133
+ return patched
134
+
135
+ _patch(ns, "generate_content", unary)
136
+ _patch(ns, "generate_content_stream", streaming)
137
+
138
+
139
+ def _wrap_legacy_model(model: Any, config: ImpactConfig) -> None:
140
+ model_name = attr(model, "model_name")
141
+
142
+ def _contents(args, kwargs):
143
+ return args[0] if args else kwargs.get("contents")
144
+
145
+ def sync_gen(original):
146
+ def patched(*args, **kwargs):
147
+ start = time.time()
148
+ contents = _contents(args, kwargs)
149
+ result = original(*args, **kwargs)
150
+ if bool(kwargs.get("stream")) and is_sync_stream(result):
151
+ on_chunk, on_done = _stream_callbacks(config, contents, model_name, start)
152
+ return capture_sync_stream(result, on_chunk, on_done)
153
+ _emit(config, contents, _text(result), attr(result, "usage_metadata"), model_name, start)
154
+ return result
155
+ return patched
156
+
157
+ def async_gen(original):
158
+ def patched(*args, **kwargs):
159
+ start = time.time()
160
+ contents = _contents(args, kwargs)
161
+ streaming = bool(kwargs.get("stream"))
162
+ result = original(*args, **kwargs)
163
+ async def run():
164
+ resolved = await result
165
+ if streaming and is_async_stream(resolved):
166
+ on_chunk, on_done = _stream_callbacks(config, contents, model_name, start)
167
+ return capture_async_stream(resolved, on_chunk, on_done)
168
+ _emit(config, contents, _text(resolved), attr(resolved, "usage_metadata"), model_name, start)
169
+ return resolved
170
+ return run()
171
+ return patched
172
+
173
+ _patch(model, "generate_content", sync_gen)
174
+ _patch(model, "generate_content_async", async_gen)
175
+
176
+
177
+ def wrap_google(client: Any, config: ImpactConfig) -> Any:
178
+ """Wrap a Google Gemini client (new ``google-genai`` or legacy
179
+ ``google-generativeai``). Returns the same client, mutated in place."""
180
+ models = getattr(client, "models", None)
181
+ if models is not None and hasattr(models, "generate_content"):
182
+ _wrap_genai_namespace(models, config)
183
+ aio = getattr(client, "aio", None)
184
+ aio_models = getattr(aio, "models", None) if aio is not None else None
185
+ if aio_models is not None:
186
+ _wrap_genai_namespace(aio_models, config)
187
+ return client
188
+
189
+ if hasattr(client, "generate_content"):
190
+ _wrap_legacy_model(client, config)
191
+ return client
192
+
193
+ raise ValueError(
194
+ "[doppel-sdk] wrap_google: unrecognised Google client. Pass a genai "
195
+ "Client (google-genai) or a GenerativeModel (google-generativeai)."
196
+ )
@@ -0,0 +1,17 @@
1
+ """OpenAI interceptor (and the OpenAI-compatible family)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .._config import ImpactConfig
8
+ from ._openai_compat import wrap_openai_compatible
9
+
10
+
11
+ def wrap_openai(client: Any, config: ImpactConfig) -> Any:
12
+ """Intercept an ``OpenAI``/``AsyncOpenAI`` client's ``chat.completions.create``.
13
+
14
+ Captures sync and async calls, streaming and non-streaming. Returns the same
15
+ client (mutated in place); wrapping is idempotent.
16
+ """
17
+ return wrap_openai_compatible(client, config, provider="openai")
@@ -0,0 +1,20 @@
1
+ """OpenRouter interceptor — OpenAI-wire-compatible with vendor + cost capture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .._config import ImpactConfig
8
+ from ._openai_compat import wrap_openai_compatible
9
+
10
+
11
+ def wrap_openrouter(client: Any, config: ImpactConfig) -> Any:
12
+ """Intercept an OpenAI SDK client pointed at OpenRouter.
13
+
14
+ Tags runs as ``provider='openrouter'``, derives the model author as
15
+ ``vendor`` from the namespaced model id, and captures OpenRouter's real USD
16
+ ``usage.cost`` when present. Handles sync/async and streaming.
17
+ """
18
+ return wrap_openai_compatible(
19
+ client, config, provider="openrouter", derive_vendor=True, capture_cost=True
20
+ )
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: doppel-sdk
3
+ Version: 0.1.0
4
+ Summary: Official Doppel Python SDK
5
+ License: MIT
6
+ Keywords: api,doppel,llm,sdk,shadow,testing
7
+ Requires-Python: >=3.9
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest; extra == 'dev'
10
+ Requires-Dist: pytest-asyncio; extra == 'dev'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # doppel-sdk — Python
14
+
15
+ Official Doppel SDK for Python. Wrap your existing LLM client and every call is
16
+ captured and forwarded to Doppel — no change to your call sites.
17
+
18
+ Supports **OpenAI**, **OpenRouter** (and the OpenAI-compatible family: DeepSeek,
19
+ Qwen, Grok, Mistral, …), **Anthropic**, and **Google Gemini** — across **sync
20
+ and async** clients, and **streaming and non-streaming** calls. Zero runtime
21
+ dependencies.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install doppel-sdk
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```python
32
+ from doppel_sdk import DoppelClient
33
+ from openai import OpenAI
34
+
35
+ # Reads DOPPEL_API_KEY from the environment (or pass api_key=...).
36
+ doppel = DoppelClient(shadow_model="gpt-4o-mini")
37
+
38
+ client = doppel.wrap_openai(OpenAI())
39
+ client.chat.completions.create(
40
+ model="gpt-4o",
41
+ messages=[{"role": "user", "content": "Hello"}],
42
+ )
43
+ ```
44
+
45
+ Other providers:
46
+
47
+ ```python
48
+ doppel.wrap_anthropic(Anthropic()) # messages.create
49
+ doppel.wrap_google(genai.Client()) # google-genai (or a GenerativeModel)
50
+ doppel.wrap_openrouter(OpenAI(base_url=".../openrouter"))
51
+ ```
52
+
53
+ Async clients (`AsyncOpenAI`, `AsyncAnthropic`, `client.aio.models`) work the
54
+ same way — the interceptor detects and handles them automatically. Streaming
55
+ calls are captured too; for OpenAI/OpenRouter the SDK auto-enables
56
+ `stream_options.include_usage` so token usage is recorded.
57
+
58
+ In short-lived processes (serverless, CLI), flush pending deliveries before exit:
59
+
60
+ ```python
61
+ doppel.flush()
62
+ ```
63
+
64
+ ## Configuration
65
+
66
+ | Setting | Argument | Environment variable | Default |
67
+ |---|---|---|---|
68
+ | API key | `api_key` | `DOPPEL_API_KEY` | — (required) |
69
+ | Server URL | `server_url` | `DOPPEL_SERVER_URL` | `https://api.doppel.in` |
70
+ | Shadow model | `shadow_model` | — | none (chosen in the dashboard) |
71
+ | Debug logs | `debug` | `DOPPEL_DEBUG` (`1`/`true`) | off |
72
+
73
+ ## Development
74
+
75
+ ```bash
76
+ pip install -e ".[dev]"
77
+ pytest
78
+ ```
79
+
80
+ ## Publishing
81
+
82
+ Pushing a `py-v*` tag runs the tests, builds, and publishes to PyPI (trusted
83
+ publishing — no token):
84
+
85
+ ```bash
86
+ # bump version in pyproject.toml first, then:
87
+ git tag py-v0.1.0
88
+ git push origin py-v0.1.0
89
+ ```
@@ -0,0 +1,15 @@
1
+ doppel_sdk/__init__.py,sha256=4fUqoQvbwEY4y9d6PVF6VcbVGYwsFyx_kc57e3GpqcE,921
2
+ doppel_sdk/_config.py,sha256=3X0xle-gnqKL1lVu37Yuj9-oOUodNTA59cQO5a3AmHM,1796
3
+ doppel_sdk/_transport.py,sha256=KVN3dbhRVQXspbT7hO4eYfZugYU4OsE8caZ9EN_fjTw,3095
4
+ doppel_sdk/_utils.py,sha256=oS4DNO08UdMUxewTsRujnA1jCH6NR0nOYn7BbYV4TFc,1115
5
+ doppel_sdk/client.py,sha256=RJ1-B8_-N6xzXzHgp6dxtS0De-P8ONLefuCtoZtHElM,1705
6
+ doppel_sdk/interceptors/__init__.py,sha256=CRyjcAUzWfOrEmtHdS-p3PHDBci4UD0PO00i-4Erk3U,48
7
+ doppel_sdk/interceptors/_openai_compat.py,sha256=Cn_ASl6d4CiUkAJtRtSDFmVY8lNtieMtquQcda6mh7w,5722
8
+ doppel_sdk/interceptors/_stream.py,sha256=RnqqYspmnhr2y7UfB9RShZhsS5cterBjh80v22jK434,3118
9
+ doppel_sdk/interceptors/anthropic.py,sha256=QjK6rzARhvwCZhzWTuV4xWQnpShsAgFOK77Z_tikmDM,5053
10
+ doppel_sdk/interceptors/google.py,sha256=H4gkSD_bGVHuYnvH01tZf5HHBE-Km2oR0G0QLoFgGG4,7504
11
+ doppel_sdk/interceptors/openai.py,sha256=UeqkqBy1nbkVEMscS9bMRNE-Lm23VQZX0DWUC3baDeg,568
12
+ doppel_sdk/interceptors/openrouter.py,sha256=Ynmv44hEhzCEWB189qsq0qGE31-tON6kqQYee9B9R30,711
13
+ doppel_sdk-0.1.0.dist-info/METADATA,sha256=YUxgW9gkxu8pPl6OVWuVbtvlXB7GZQ8LcUazaeTMTes,2416
14
+ doppel_sdk-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
15
+ doppel_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any