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 +34 -0
- doppel_sdk/_config.py +61 -0
- doppel_sdk/_transport.py +81 -0
- doppel_sdk/_utils.py +40 -0
- doppel_sdk/client.py +53 -0
- doppel_sdk/interceptors/__init__.py +1 -0
- doppel_sdk/interceptors/_openai_compat.py +157 -0
- doppel_sdk/interceptors/_stream.py +100 -0
- doppel_sdk/interceptors/anthropic.py +127 -0
- doppel_sdk/interceptors/google.py +196 -0
- doppel_sdk/interceptors/openai.py +17 -0
- doppel_sdk/interceptors/openrouter.py +20 -0
- doppel_sdk-0.1.0.dist-info/METADATA +89 -0
- doppel_sdk-0.1.0.dist-info/RECORD +15 -0
- doppel_sdk-0.1.0.dist-info/WHEEL +4 -0
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
|
+
)
|
doppel_sdk/_transport.py
ADDED
|
@@ -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,,
|