Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,11 @@ The client manages a singleton connection to LaunchDarkly and the associated tel

| Export | Description |
|---|---|
| `init_client(options?)` | Auto-discovers and initializes `launchdarkly-server-sdk` (optional dep, loaded via `importlib`). Optional — the first AI API call triggers lazy init when `LD_SDK_KEY` is set. Accepts optional overrides for SDK key, base URIs, service name, environment, and OTLP endpoint. Returns `Awaitable[LDClientInterface]`. |
| `init_client(client=...)` | **BYOC overload** — accepts a pre-initialized `LDClientInterface`. Stores it directly without calling the SDK. |
| `init_client(options?)` | Auto-discovers and initializes `launchdarkly-server-sdk` (optional dep, loaded via `importlib`). Optional — the first AI API call triggers lazy init when `LD_SDK_KEY` is set. Accepts optional overrides for SDK key, base URIs, service name, environment, and OTLP endpoint. Returns `Awaitable[LDClientInterface]`. On every successful path, including the already-initialized path, flushes `$ld:ai:sdk:info` for any LaunchDarkly AI packages that have not yet reported. |
| `init_client(client=...)` | **BYOC overload** — accepts a pre-initialized `LDClientInterface`. Stores it directly without calling the SDK. Flushes pending `$ld:ai:sdk:info` events. |
| `get_client()` | Returns the initialized `LDClientInterface`. Throws if initialization has not completed. |
| `shutdown()` | Flushes all pending events and telemetry, then closes the client. Must be awaited before the process exits. |
| `shutdown()` | Flushes all pending events and telemetry, then closes the client. Must be awaited before the process exits. Clears sdk-info reporting so a later client reports again. |
| `register_ai_sdk_package(name, version)` | Records a LaunchDarkly AI package identity. Handler and convenience packages call this at import time. |

### Core Data Types

Expand Down
3 changes: 3 additions & 0 deletions packages/ai/src/launchdarkly_ai_python/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@
__version__ = "0.1.3" # x-release-please-version

from launchdarkly_ai_server import * # noqa: F403
from launchdarkly_ai_server import register_ai_sdk_package

register_ai_sdk_package("launchdarkly-ai-python", __version__)
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

__version__ = "0.1.4" # x-release-please-version

from launchdarkly_ai_server import register_ai_sdk_package

from . import native_graph # noqa: F401
from .builtins import (
ClaudeBash,
Expand Down Expand Up @@ -44,3 +46,5 @@
"partition_tools",
"to_claude_agents",
]

register_ai_sdk_package("launchdarkly-ai-claude-agents", __version__)
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

__version__ = "0.1.4" # x-release-please-version

from launchdarkly_ai_server import register_ai_sdk_package

from .handler import claude_messages, create_claude_messages_handler

__all__ = ["claude_messages", "create_claude_messages_handler"]

register_ai_sdk_package("launchdarkly-ai-claude-messages", __version__)
3 changes: 2 additions & 1 deletion packages/client/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ No other `launchdarkly-ai-*` package may define or duplicate these. They import
| File | Responsibility |
|---|---|
| `src/launchdarkly_ai_server/conversation.py` | `conversation_id`, `ConversationIdSpanProcessor` — stamps `gen_ai.conversation.id` |
| `src/launchdarkly_ai_server/sdk_info.py` | `$ld:ai:sdk:info` package registry and flush |
| `src/launchdarkly_ai_server/lifecycle.py` | `init_client`, `get_client`, `shutdown`, `extract_variation` |
| `src/launchdarkly_ai_server/client.py` | `config()`, `ConfigInstance` |
| `src/launchdarkly_ai_server/tracking.py` | `execute_and_track`, `execute_and_stream`, `wrap_tool_handlers`, `parse_usage` |
Expand All @@ -42,7 +43,7 @@ Key symbols exported from `launchdarkly_ai_server`:

```python
# Lifecycle
from launchdarkly_ai_server import init_client, get_client, shutdown, extract_variation
from launchdarkly_ai_server import init_client, get_client, shutdown, extract_variation, register_ai_sdk_package
from launchdarkly_ai_server import conversation_id, set_conversation_id_if_absent, ConversationIdSpanProcessor

# Types
Expand Down
6 changes: 6 additions & 0 deletions packages/client/src/launchdarkly_ai_server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
resolve_handlers,
resolve_tools,
)
from .sdk_info import SDK_INFO_CONTEXT, SDK_INFO_EVENT, register_ai_sdk_package
from .tracking import execute_and_stream, execute_and_track, wrap_tool_handlers
from .types import (
NATIVE_TOOL_KEY,
Expand Down Expand Up @@ -199,6 +200,9 @@
"shutdown",
"extract_variation",
"inspect_config",
"SDK_INFO_CONTEXT",
"SDK_INFO_EVENT",
"register_ai_sdk_package",
# judges
"build_judge_tasks",
"run_judge",
Expand All @@ -215,3 +219,5 @@
"resolve_graph",
"GraphInstance",
]

register_ai_sdk_package("launchdarkly-ai-server", __version__)
7 changes: 6 additions & 1 deletion packages/client/src/launchdarkly_ai_server/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import os
from typing import Any

from .sdk_info import flush_ai_sdk_info, reset_ai_sdk_info
from .types import InitClientOptions

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -139,12 +140,14 @@ async def init_client(

# Idempotent — if already initialized, return the existing client
if _client is not None:
flush_ai_sdk_info(_client)
return _client

# BYOC path — pre-initialized client
if client is not None:
_client = client
_setup_telemetry(opts.get("sdkKey", "byoc"), opts)
flush_ai_sdk_info(_client)
return _client

# Resolve SDK key
Expand Down Expand Up @@ -191,6 +194,7 @@ async def init_client(

_client = ld_client
_setup_telemetry(sdk_key, opts)
flush_ai_sdk_info(_client)
return _client


Expand All @@ -207,6 +211,7 @@ async def shutdown() -> None:
# Null the singleton before any awaits so a second call is a no-op
_client = None
_tracer_provider = None
reset_ai_sdk_info()

if local_provider is not None:
try:
Expand Down Expand Up @@ -256,7 +261,7 @@ async def inspect_config(

- Never raises — returns ``{"enabled": False, "config": None, "meta": None}``
on any error (unreachable LD, bad key, unparseable config, etc.)
- Does not emit any LaunchDarkly telemetry events
- Does not emit generation, duration, or token tracking events
- Does not call any AI provider

Returns a dict with keys:
Expand Down
60 changes: 60 additions & 0 deletions packages/client/src/launchdarkly_ai_server/sdk_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from __future__ import annotations

from typing import Any

from .types import LDContext

SDK_INFO_EVENT = "$ld:ai:sdk:info"

SDK_INFO_CONTEXT: LDContext = {
"kind": "ld_ai",
"key": "ld-internal-tracking",
"anonymous": True,
}

_SDK_INFO_LANGUAGE = "python"

_known: dict[str, tuple[str, str]] = {}
_reported: set[str] = set()


def register_ai_sdk_package(name: str, version: str) -> None:
"""Record a LaunchDarkly AI package so it can report on the next flush."""
package_id = f"{name}@{version}"
if package_id in _known:
return
_known[package_id] = (name, version)


def flush_ai_sdk_info(client: Any) -> None:
"""Emit ``$ld:ai:sdk:info`` once per unreported registered package."""
if len(_known) == len(_reported):
return

from .utils import to_ld_context

context = to_ld_context(client, SDK_INFO_CONTEXT)
for package_id, (name, version) in list(_known.items()):
if package_id in _reported:
continue
try:
client.track(
SDK_INFO_EVENT,
context,
{
"aiSdkName": name,
"aiSdkVersion": version,
"aiSdkLanguage": _SDK_INFO_LANGUAGE,
},
1,
)
except Exception:
pass
_reported.add(package_id)


def reset_ai_sdk_info(*, clear_known: bool = False) -> None:
"""Mark packages unreported. Optionally drop the registered set (tests)."""
_reported.clear()
if clear_known:
_known.clear()
52 changes: 52 additions & 0 deletions packages/client/tests/test_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,20 @@
import launchdarkly_ai_server.lifecycle as lifecycle_module
from launchdarkly_ai_server import get_client, init_client, inspect_config, shutdown
from launchdarkly_ai_server.lifecycle import _reset_for_testing
from launchdarkly_ai_server.sdk_info import (
register_ai_sdk_package,
reset_ai_sdk_info,
)


@pytest.fixture(autouse=True)
def reset_singleton() -> None:
"""Ensure a fresh singleton for every test."""
_reset_for_testing()
reset_ai_sdk_info(clear_known=True)
yield
_reset_for_testing()
reset_ai_sdk_info(clear_known=True)


def _make_stub_client() -> MagicMock:
Expand Down Expand Up @@ -67,6 +73,39 @@ async def test_get_client_returns_passed_client(self) -> None:
await init_client(client=stub)
assert get_client() is stub

async def test_flushes_registered_ai_package_information(self) -> None:
stub = _make_stub_client()
register_ai_sdk_package("launchdarkly-ai-server", "0.1.3")

with patch.object(lifecycle_module, "_setup_telemetry", return_value=None):
await init_client(client=stub)

stub.track.assert_called_once_with(
"$ld:ai:sdk:info",
{"kind": "ld_ai", "key": "ld-internal-tracking", "anonymous": True},
{
"aiSdkName": "launchdarkly-ai-server",
"aiSdkVersion": "0.1.3",
"aiSdkLanguage": "python",
},
1,
)

async def test_flushes_package_registered_after_initialization(self) -> None:
stub = _make_stub_client()
register_ai_sdk_package("launchdarkly-ai-server", "0.1.3")

with patch.object(lifecycle_module, "_setup_telemetry", return_value=None):
await init_client(client=stub)
stub.track.reset_mock()
register_ai_sdk_package("launchdarkly-ai-openai-agents", "0.1.4")
await init_client()

stub.track.assert_called_once()
assert (
stub.track.call_args.args[2]["aiSdkName"] == "launchdarkly-ai-openai-agents"
)

async def test_does_not_call_node_sdk(self) -> None:
stub = _make_stub_client()
mock_import = MagicMock()
Expand Down Expand Up @@ -219,6 +258,19 @@ async def test_allows_reinitialization(self) -> None:
await init_client(client=stub2)
assert get_client() is stub2

async def test_reemits_registered_packages_after_shutdown(self) -> None:
stub1 = _make_stub_client()
stub2 = _make_stub_client()
register_ai_sdk_package("launchdarkly-ai-server", "0.1.3")

with patch.object(lifecycle_module, "_setup_telemetry", return_value=None):
await init_client(client=stub1)
await shutdown()
await init_client(client=stub2)

stub1.track.assert_called_once()
stub2.track.assert_called_once()

async def test_idempotent_double_shutdown(self) -> None:
stub = _make_stub_client()
with patch.object(lifecycle_module, "_setup_telemetry", return_value=None):
Expand Down
Loading
Loading