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.
- doppel_sdk-0.1.0/.gitignore +21 -0
- doppel_sdk-0.1.0/PKG-INFO +89 -0
- doppel_sdk-0.1.0/README.md +77 -0
- doppel_sdk-0.1.0/doppel_sdk/__init__.py +34 -0
- doppel_sdk-0.1.0/doppel_sdk/_config.py +61 -0
- doppel_sdk-0.1.0/doppel_sdk/_transport.py +81 -0
- doppel_sdk-0.1.0/doppel_sdk/_utils.py +40 -0
- doppel_sdk-0.1.0/doppel_sdk/client.py +53 -0
- doppel_sdk-0.1.0/doppel_sdk/interceptors/__init__.py +1 -0
- doppel_sdk-0.1.0/doppel_sdk/interceptors/_openai_compat.py +157 -0
- doppel_sdk-0.1.0/doppel_sdk/interceptors/_stream.py +100 -0
- doppel_sdk-0.1.0/doppel_sdk/interceptors/anthropic.py +127 -0
- doppel_sdk-0.1.0/doppel_sdk/interceptors/google.py +196 -0
- doppel_sdk-0.1.0/doppel_sdk/interceptors/openai.py +17 -0
- doppel_sdk-0.1.0/doppel_sdk/interceptors/openrouter.py +20 -0
- doppel_sdk-0.1.0/pyproject.toml +23 -0
- doppel_sdk-0.1.0/tests/test_sdk.py +321 -0
|
@@ -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
|