doppel-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,21 @@
1
+ node_modules
2
+ dist
3
+ .DS_Store
4
+ *.log
5
+
6
+ # JS SDK
7
+ sdks/javascript/dist
8
+ sdks/javascript/node_modules
9
+
10
+ # Python SDK
11
+ __pycache__/
12
+ *.pyc
13
+ .pytest_cache/
14
+ sdks/python/*.egg-info
15
+ sdks/python/dist
16
+ sdks/python/.venv
17
+
18
+ # Java SDK
19
+ sdks/java/target
20
+ sdks/java/.gradle
21
+ sdks/java/build
@@ -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,77 @@
1
+ # doppel-sdk — Python
2
+
3
+ Official Doppel SDK for Python. Wrap your existing LLM client and every call is
4
+ captured and forwarded to Doppel — no change to your call sites.
5
+
6
+ Supports **OpenAI**, **OpenRouter** (and the OpenAI-compatible family: DeepSeek,
7
+ Qwen, Grok, Mistral, …), **Anthropic**, and **Google Gemini** — across **sync
8
+ and async** clients, and **streaming and non-streaming** calls. Zero runtime
9
+ dependencies.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install doppel-sdk
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```python
20
+ from doppel_sdk import DoppelClient
21
+ from openai import OpenAI
22
+
23
+ # Reads DOPPEL_API_KEY from the environment (or pass api_key=...).
24
+ doppel = DoppelClient(shadow_model="gpt-4o-mini")
25
+
26
+ client = doppel.wrap_openai(OpenAI())
27
+ client.chat.completions.create(
28
+ model="gpt-4o",
29
+ messages=[{"role": "user", "content": "Hello"}],
30
+ )
31
+ ```
32
+
33
+ Other providers:
34
+
35
+ ```python
36
+ doppel.wrap_anthropic(Anthropic()) # messages.create
37
+ doppel.wrap_google(genai.Client()) # google-genai (or a GenerativeModel)
38
+ doppel.wrap_openrouter(OpenAI(base_url=".../openrouter"))
39
+ ```
40
+
41
+ Async clients (`AsyncOpenAI`, `AsyncAnthropic`, `client.aio.models`) work the
42
+ same way — the interceptor detects and handles them automatically. Streaming
43
+ calls are captured too; for OpenAI/OpenRouter the SDK auto-enables
44
+ `stream_options.include_usage` so token usage is recorded.
45
+
46
+ In short-lived processes (serverless, CLI), flush pending deliveries before exit:
47
+
48
+ ```python
49
+ doppel.flush()
50
+ ```
51
+
52
+ ## Configuration
53
+
54
+ | Setting | Argument | Environment variable | Default |
55
+ |---|---|---|---|
56
+ | API key | `api_key` | `DOPPEL_API_KEY` | — (required) |
57
+ | Server URL | `server_url` | `DOPPEL_SERVER_URL` | `https://api.doppel.in` |
58
+ | Shadow model | `shadow_model` | — | none (chosen in the dashboard) |
59
+ | Debug logs | `debug` | `DOPPEL_DEBUG` (`1`/`true`) | off |
60
+
61
+ ## Development
62
+
63
+ ```bash
64
+ pip install -e ".[dev]"
65
+ pytest
66
+ ```
67
+
68
+ ## Publishing
69
+
70
+ Pushing a `py-v*` tag runs the tests, builds, and publishes to PyPI (trusted
71
+ publishing — no token):
72
+
73
+ ```bash
74
+ # bump version in pyproject.toml first, then:
75
+ git tag py-v0.1.0
76
+ git push origin py-v0.1.0
77
+ ```
@@ -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"
@@ -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
@@ -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__")
@@ -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