From 31f665c370cc1bbc520bc7d3add209b6fff5ca1a Mon Sep 17 00:00:00 2001 From: Jeff Dupont Date: Mon, 26 Oct 2026 12:52:19 -0700 Subject: [PATCH 1/9] fix(AIC-3598): make Python packaging and public types GA-ready - Production/Stable classifiers on all 14 packages (vercel-* had none). - Raise third-party floors that crossed a breaking line to the tested versions in uv.lock (langchain-core, langgraph, openai, openai-agents, anthropic, httpx, typesafe-sdk). - Add an optional [ld] extra for launchdarkly-server-sdk on launchdarkly-ai-server and launchdarkly-ai-python; READMEs install it. - LDClientInterface becomes a Protocol that ldclient.LDClient satisfies; init_client and get_client return it. - Handler protocol and every handler type tool_handlers as dict[str, Callable | NativeTool]; offline evaluations pass a NativeTool through unwrapped, so langchain-agents now guards with callable(). - Native graph adapters take Awaitable[GraphDefinition], a per-package options TypedDict, and return NativeGraphRunner. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +-- packages/ai/pyproject.toml | 3 +- packages/claude-agents/agents.md | 2 +- .../launchdarkly_ai_claude_agents.txt | 9 +++- packages/claude-agents/pyproject.toml | 4 +- .../launchdarkly_ai_claude_agents/__init__.py | 3 +- .../launchdarkly_ai_claude_agents/handler.py | 12 +++-- .../native_graph.py | 37 +++++++++++--- packages/claude-messages/pyproject.toml | 4 +- .../handler.py | 11 +++-- packages/client/README.md | 4 +- packages/client/agents.md | 8 +-- .../api-report/launchdarkly_ai_server.txt | 20 ++++---- packages/client/pyproject.toml | 4 +- .../src/launchdarkly_ai_server/__init__.py | 2 + .../src/launchdarkly_ai_server/lifecycle.py | 13 ++--- .../src/launchdarkly_ai_server/tracking.py | 22 ++++++--- .../src/launchdarkly_ai_server/types.py | 49 +++++++++++++------ packages/client/tests/test_public_surface.py | 1 + packages/langchain-agents/agents.md | 2 +- .../launchdarkly_ai_langchain_agents.txt | 6 ++- packages/langchain-agents/pyproject.toml | 6 +-- .../__init__.py | 3 +- .../handler.py | 14 +++--- .../native_graph.py | 34 ++++++++++--- .../langchain-agents/tests/test_handler.py | 12 +++++ packages/langchain-messages/pyproject.toml | 4 +- .../handler.py | 9 ++-- packages/litellm-agents/README.md | 2 +- .../launchdarkly_ai_litellm_agents.txt | 8 ++- packages/litellm-agents/pyproject.toml | 4 +- .../__init__.py | 3 +- .../launchdarkly_ai_litellm_agents/handler.py | 5 +- .../native_graph.py | 32 ++++++++++-- packages/litellm-messages/README.md | 2 +- packages/litellm-messages/pyproject.toml | 2 +- .../handler.py | 7 +-- packages/openai-agents/agents.md | 2 +- .../launchdarkly_ai_openai_agents.txt | 5 +- packages/openai-agents/pyproject.toml | 6 +-- .../launchdarkly_ai_openai_agents/__init__.py | 3 +- .../launchdarkly_ai_openai_agents/handler.py | 13 ++--- .../native_graph.py | 34 ++++++++++--- packages/openai-messages/pyproject.toml | 4 +- .../handler.py | 9 ++-- packages/opencode-agents/README.md | 2 +- packages/opencode-agents/pyproject.toml | 4 +- .../handler.py | 7 +-- packages/typesafe/pyproject.toml | 4 +- .../src/launchdarkly_ai_typesafe/handler.py | 5 +- packages/vercel-agents/README.md | 2 +- .../launchdarkly_ai_vercel_agents.txt | 7 ++- packages/vercel-agents/pyproject.toml | 8 +++ .../launchdarkly_ai_vercel_agents/__init__.py | 3 +- .../launchdarkly_ai_vercel_agents/handler.py | 4 +- .../native_graph.py | 33 ++++++++++--- packages/vercel-messages/README.md | 2 +- packages/vercel-messages/pyproject.toml | 8 +++ .../handler.py | 6 +-- uv.lock | 36 ++++++++------ 60 files changed, 397 insertions(+), 181 deletions(-) diff --git a/README.md b/README.md index c66ff29a..0ce5db8d 100644 --- a/README.md +++ b/README.md @@ -96,15 +96,15 @@ Tier 0 — Core Client (launchdarkly-ai-server) ### 1. Install ```bash -pip install launchdarkly-ai-python launchdarkly-server-sdk launchdarkly-ai-openai-messages +pip install "launchdarkly-ai-python[ld]" launchdarkly-ai-openai-messages ``` -`launchdarkly-ai-python` is a thin barrel that re-exports all of `launchdarkly-ai-server`. The base LaunchDarkly Python SDK is a peer dependency rather than a bundled one, so install `launchdarkly-server-sdk` alongside it. `init_client()` discovers it at runtime, or you can skip it entirely by passing a pre-initialized client with `init_client(client=my_client)`. +`launchdarkly-ai-python` is a thin barrel that re-exports all of `launchdarkly-ai-server`. The base LaunchDarkly Python SDK is an optional dependency rather than a bundled one; the `[ld]` extra installs `launchdarkly-server-sdk`. `init_client()` discovers it at runtime, or you can skip it entirely by passing a pre-initialized client with `init_client(client=my_client)`. **With telemetry** (recommended for production) — traces export to the LaunchDarkly Observability dashboard: ```bash -pip install "launchdarkly-ai-python[otel]" launchdarkly-server-sdk launchdarkly-ai-openai-messages +pip install "launchdarkly-ai-python[ld,otel]" launchdarkly-ai-openai-messages ``` No code changes are needed — `init_client()` detects whether the OTel packages are present at runtime and configures the tracer provider automatically. If they are absent, the SDK logs a one-time warning and continues normally. @@ -571,7 +571,7 @@ When running inside `graph()`, every node's events carry the graph key, tool inv ### Optional dependencies -The OpenTelemetry SDK packages are **optional** — detected at runtime via `importlib`. The LaunchDarkly server SDK (`launchdarkly-server-sdk`) is also an optional dependency; pass a pre-initialized client to `init_client(client=...)` if you bring your own. +The OpenTelemetry SDK packages are **optional** — detected at runtime via `importlib`. The LaunchDarkly server SDK (`launchdarkly-server-sdk`) is also an optional dependency, installed by the `[ld]` extra; pass a pre-initialized client to `init_client(client=...)` if you bring your own. **OTel packages** (installed via `pip install "launchdarkly-ai-python[otel]"` or `pip install "launchdarkly-ai-server[otel]"`): - **If installed:** `init_client()` sets up a `TracerProvider` with a GZIP-compressed OTLP HTTP exporter and W3C trace-context/baggage propagators — no code changes needed. diff --git a/packages/ai/pyproject.toml b/packages/ai/pyproject.toml index a4942f52..ec9cea0e 100644 --- a/packages/ai/pyproject.toml +++ b/packages/ai/pyproject.toml @@ -9,7 +9,7 @@ license = "Apache-2.0" authors = [{name = "LaunchDarkly", email = "team@launchdarkly.com"}] keywords = ["launchdarkly", "ai", "feature-flags", "sdk"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", @@ -24,6 +24,7 @@ Repository = "https://github.com/launchdarkly/python-ai-sdk" [project.optional-dependencies] otel = ["launchdarkly-ai-server[otel]"] +ld = ["launchdarkly-ai-server[ld]"] [build-system] requires = ["hatchling>=1.32,<2"] diff --git a/packages/claude-agents/agents.md b/packages/claude-agents/agents.md index 684928a7..76c4586d 100644 --- a/packages/claude-agents/agents.md +++ b/packages/claude-agents/agents.md @@ -55,7 +55,7 @@ def claude_graph(key: str, *, cwd=None, setting_sources=None, **options): ... # Native graph adapter — builds a Claude code-agents graph from a resolved GraphDefinition. # opts: context, tool_handlers, and the host options cwd and setting_sources -def to_claude_agents(def_promise: Awaitable[dict], opts: dict | None = None): ... +def to_claude_agents(def_promise: Awaitable[GraphDefinition], opts: ClaudeNativeGraphOptions | None = None) -> NativeGraphRunner: ... # NativeTool sentinels for Claude Code built-in capabilities ClaudeBash: NativeTool diff --git a/packages/claude-agents/api-report/launchdarkly_ai_claude_agents.txt b/packages/claude-agents/api-report/launchdarkly_ai_claude_agents.txt index d56e2caa..d49559a4 100644 --- a/packages/claude-agents/api-report/launchdarkly_ai_claude_agents.txt +++ b/packages/claude-agents/api-report/launchdarkly_ai_claude_agents.txt @@ -7,6 +7,11 @@ ClaudeBash = NativeTool('Bash') ClaudeEdit = NativeTool('Edit') ClaudeGlob = NativeTool('Glob') ClaudeGrep = NativeTool('Grep') +ClaudeNativeGraphOptions(TypedDict, total=False) [class] + context: LDContext + cwd: str | os.PathLike[str] + setting_sources: list[SettingSource] + tool_handlers: dict[str, Callable[..., Any] | NativeTool] ClaudeNotebookEdit = NativeTool('NotebookEdit') ClaudeRead = NativeTool('Read') ClaudeTodoWrite = NativeTool('TodoWrite') @@ -14,5 +19,5 @@ ClaudeWebFetch = NativeTool('WebFetch') ClaudeWebSearch = NativeTool('WebSearch') ClaudeWrite = NativeTool('Write') create_claude_agents_handler(*, capture_content: bool | None = None, cwd: str | os.PathLike[str] | None = None, setting_sources: list[SettingSource] | None = None) -> ProviderHandler -partition_tools(config_tools: dict[str, Any] | None, tool_handlers: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any], list[str]] -to_claude_agents(def_promise: Any, opts: dict[str, Any] | None = None) -> Any +partition_tools(config_tools: dict[str, Any] | None, tool_handlers: dict[str, Callable[..., Any] | NativeTool]) -> tuple[dict[str, Any], dict[str, Any], list[str]] +to_claude_agents(def_promise: Awaitable[GraphDefinition], opts: ClaudeNativeGraphOptions | None = None) -> NativeGraphRunner diff --git a/packages/claude-agents/pyproject.toml b/packages/claude-agents/pyproject.toml index 71294940..79a9e6fb 100644 --- a/packages/claude-agents/pyproject.toml +++ b/packages/claude-agents/pyproject.toml @@ -5,7 +5,7 @@ requires-python = ">=3.12" dependencies = [ "launchdarkly-ai-server>=0.4.1", "opentelemetry-api>=1.25", - "anthropic>=0.40", + "anthropic>=0.113", "claude-agent-sdk>=0.2.121", ] description = "Anthropic Claude agent handler for LaunchDarkly AI SDK" @@ -14,7 +14,7 @@ license = "Apache-2.0" authors = [{name = "LaunchDarkly", email = "team@launchdarkly.com"}] keywords = ["launchdarkly", "ai", "anthropic", "claude", "agents"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/__init__.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/__init__.py index d74b486c..6755313e 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/__init__.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/__init__.py @@ -27,13 +27,14 @@ create_claude_agents_handler, partition_tools, ) -from .native_graph import to_claude_agents +from .native_graph import ClaudeNativeGraphOptions, to_claude_agents __all__ = [ "ClaudeBash", "ClaudeEdit", "ClaudeGlob", "ClaudeGrep", + "ClaudeNativeGraphOptions", "ClaudeNotebookEdit", "ClaudeRead", "ClaudeTodoWrite", diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py index 7dd92792..f93cb4cc 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py @@ -213,7 +213,7 @@ async def _execute(args: Any, _name: str = tool_name) -> dict[str, Any]: def partition_tools( config_tools: dict[str, Any] | None, - tool_handlers: dict[str, Any], + tool_handlers: dict[str, Callable[..., Any] | NativeTool], ) -> tuple[dict[str, Any], dict[str, Any], list[str]]: """ Returns (native_tool_map, user_config_tools, native_tool_names). @@ -239,7 +239,9 @@ def partition_tools( return native_tool_map, user_config_tools, list(native_tool_map.keys()) -def _native_tool_aliases(tool_handlers: dict[str, Any]) -> dict[str, str]: +def _native_tool_aliases( + tool_handlers: dict[str, Callable[..., Any] | NativeTool], +) -> dict[str, str]: """AI Config key → provider tool name, for the config's native tools only. ``partition_tools``'s ``native_tool_map`` is keyed the other way round, by provider name, which @@ -778,7 +780,7 @@ def _create_claude_agents_handler( async def _call_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: @@ -964,7 +966,7 @@ async def _call_impl( def _stream_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: @@ -1035,7 +1037,7 @@ def _map_claude_stream_event( async def _stream_gen( config: AiConfigRep, user_input: str, - tool_handlers: dict[str, Any], + tool_handlers: dict[str, Callable[..., Any] | NativeTool], variables: dict[str, Any], history: list[dict[str, Any]] | None = None, *, diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py index a1c811f2..f6fac5b9 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py @@ -6,16 +6,21 @@ from __future__ import annotations import logging +import os import re import time -import types import uuid -from typing import Any +from collections.abc import Awaitable, Callable +from typing import Any, TypedDict + +from claude_agent_sdk import SettingSource from launchdarkly_ai_server import ( NATIVE_TOOL_KEY, GraphDefinition, GraphNode, + LDContext, + NativeGraphRunner, NativeTool, get_client, make_track_data, @@ -59,7 +64,7 @@ def _sanitize_name(key: str) -> str: def _wrap_native_tools( - tool_handlers: dict[str, Any], + tool_handlers: dict[str, Callable[..., Any] | NativeTool], ld_context: Any, track_data: dict[str, Any], ) -> dict[str, Any]: @@ -110,7 +115,7 @@ async def _run_query( node: GraphNode, input_text: str, variables: dict[str, Any], - tool_handlers: dict[str, Any], + tool_handlers: dict[str, Callable[..., Any] | NativeTool], ld_context: Any, graph_key: str, run_id: str, @@ -219,10 +224,20 @@ async def _run_query( } +class ClaudeNativeGraphOptions(TypedDict, total=False): + """Options for :func:`to_claude_agents`.""" + + tool_handlers: dict[str, Callable[..., Any] | NativeTool] + context: LDContext + """LaunchDarkly context used for tracking events. Required for LD telemetry.""" + cwd: str | os.PathLike[str] + setting_sources: list[SettingSource] + + def to_claude_agents( - def_promise: Any, - opts: dict[str, Any] | None = None, -) -> Any: + def_promise: Awaitable[GraphDefinition], + opts: ClaudeNativeGraphOptions | None = None, +) -> NativeGraphRunner: """ Converts a resolved ``GraphDefinition`` into a nested query() multi-agent execution where each child node becomes a sub-agent tool. @@ -474,4 +489,10 @@ async def _subagent_execute( if span: span.end() - return types.SimpleNamespace(invoke=invoke) + # A class, not a SimpleNamespace, so mypy checks invoke against NativeGraphRunner. + _invoke = invoke + + class _Runner: + invoke = staticmethod(_invoke) + + return _Runner() diff --git a/packages/claude-messages/pyproject.toml b/packages/claude-messages/pyproject.toml index 4a87fab1..8f03f418 100644 --- a/packages/claude-messages/pyproject.toml +++ b/packages/claude-messages/pyproject.toml @@ -5,7 +5,7 @@ requires-python = ">=3.12" dependencies = [ "launchdarkly-ai-server>=0.4.1", "opentelemetry-api>=1.25", - "anthropic>=0.40", + "anthropic>=0.113", ] description = "Anthropic Claude messages handler for LaunchDarkly AI SDK" readme = "README.md" @@ -13,7 +13,7 @@ license = "Apache-2.0" authors = [{name = "LaunchDarkly", email = "team@launchdarkly.com"}] keywords = ["launchdarkly", "ai", "anthropic", "claude"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", diff --git a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py index ebd17db4..8fab2876 100644 --- a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py +++ b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py @@ -2,12 +2,13 @@ import asyncio import json -from collections.abc import AsyncGenerator, Mapping +from collections.abc import AsyncGenerator, Callable, Mapping from typing import Any from launchdarkly_ai_server import ( AiConfigRep, LDContext, + NativeTool, ProviderHandler, SpanMessage, SpanMessagePart, @@ -361,7 +362,7 @@ async def _run_tool_loop( config: AiConfigRep, messages: list[dict[str, Any]], system: str | None, - tool_handlers: dict[str, Any], + tool_handlers: dict[str, Callable[..., Any] | NativeTool], *, capture_content: bool = False, parent: Any = None, @@ -561,7 +562,7 @@ def _create_claude_messages_handler( async def _call_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: @@ -639,7 +640,7 @@ async def _call_impl( def _stream_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: @@ -696,7 +697,7 @@ async def _stream_gen( client: Any, config: AiConfigRep, user_input: str, - tool_handlers: dict[str, Any], + tool_handlers: dict[str, Callable[..., Any] | NativeTool], variables: dict[str, Any], history: list[dict[str, Any]] | None = None, *, diff --git a/packages/client/README.md b/packages/client/README.md index 6c50009a..3f7a7647 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -11,10 +11,10 @@ All handler packages (`launchdarkly-ai-*`) depend on this package. ### Without telemetry ```bash -pip install launchdarkly-ai-server +pip install "launchdarkly-ai-server[ld]" ``` -`launchdarkly-server-sdk` is an optional dependency — include it for standard usage, or pass a pre-initialized client to `init_client(client=...)` if you bring your own. +`launchdarkly-server-sdk` is an optional dependency, installed by the `[ld]` extra — include it for standard usage, or pass a pre-initialized client to `init_client(client=...)` if you bring your own. The SDK works fully without the OpenTelemetry packages — feature flags evaluate, handlers run, and LaunchDarkly AI events are tracked. Spans are created as no-ops. If you call `init_client()` without the OTel packages installed, the SDK logs a single warning and continues normally. diff --git a/packages/client/agents.md b/packages/client/agents.md index c83bba92..7b4664cd 100644 --- a/packages/client/agents.md +++ b/packages/client/agents.md @@ -922,11 +922,13 @@ There is deliberately **no** `python-dotenv` here: `lifecycle.py` reads `os.envi Install with `pip install "launchdarkly-ai-server[otel]"`; see [OTel Setup](#otel-setup) for the endpoint variables. -### Resolved dynamically, declared nowhere +### Optional extra (`[project.optional-dependencies] ld`) | Package | Why | |---|---| -| `launchdarkly-server-sdk` | The LaunchDarkly server SDK, reached by `importlib.import_module("ldclient")` (falling back to `launchdarkly_server_sdk`) inside `init_client()`'s options path. Undeclared because the BYOC path (`init_client(client=...)`) supplies its own client and should not have to install an unused SDK. Absent ⇒ a `RuntimeError` naming the `pip install`, only on the path that needs it. | +| `launchdarkly-server-sdk>=9.18.3,<10` | The LaunchDarkly server SDK, reached by `importlib.import_module("ldclient")` (falling back to `launchdarkly_server_sdk`) inside `init_client()`'s options path. An extra, not a dependency, because the BYOC path (`init_client(client=...)`) supplies its own client and should not have to install an unused SDK. Declared at all so pip can resolve a compatible version: before the extra existed, nothing bounded it. Absent ⇒ a `RuntimeError` naming the `pip install`, only on the path that needs it. | + +Install with `pip install "launchdarkly-ai-server[ld]"`. ### Dev-only (workspace root `[dependency-groups] dev`) — the ones with a contract attached @@ -994,7 +996,7 @@ a conversation is out of reach at this layer either way. ## Invariants to Preserve - Do not add dependencies on any `launchdarkly-ai-*` handler package. This package has no upward dependencies. -- Do not add a hard dependency on `launchdarkly-server-sdk`. It must remain an optional peer, discovered via dynamic `importlib.import_module`. +- Do not add a hard dependency on `launchdarkly-server-sdk`. It must remain optional (the `[ld]` extra), discovered via dynamic `importlib.import_module`. - Handler packages must import `LDContext` from `launchdarkly-ai-server` — not directly from any LD SDK. - Do not weaken the `parse_ai_config` validation — handler packages rely on `config` being valid when they receive it. - `parse_usage` must continue to accept `input_tokens/output_tokens`, `inputTokens/outputTokens`, and `input/output` as all existing handlers return one of these variants. diff --git a/packages/client/api-report/launchdarkly_ai_server.txt b/packages/client/api-report/launchdarkly_ai_server.txt index 36e76e69..c08f9f92 100644 --- a/packages/client/api-report/launchdarkly_ai_server.txt +++ b/packages/client/api-report/launchdarkly_ai_server.txt @@ -42,7 +42,7 @@ FDv2SkillStore [class] remove_listener(kind: str, fn: Callable[[dict[str, Any]], Any]) -> None start() -> FDv2SkillStore wait_for_skills(timeout: float = 10.0) -> bool -get_client() -> Any +get_client() -> LDClientInterface get_skill(key: str, *, version: int | None = None) -> Skill | None [async] get_skill_result(key: str, *, version: int | None = None) -> SkillOutcome [async] get_skills(refs: Sequence[SkillReference | str]) -> list[Skill] [async] @@ -88,7 +88,7 @@ HandlerResult = dict[str, Any] HandlerStreamEvent [class] has_multimodal_content(content: MessageContent) -> bool image_block_to_url(block: dict[str, Any]) -> str -init_client(options: InitClientOptions | None = None, client: Any = None) -> Any [async] +init_client(options: InitClientOptions | None = None, client: LDClientInterface | None = None) -> LDClientInterface [async] InitClientOptions = dict[str, Any] InMemorySkillStore [class] __init__(objects: dict[str, dict[str, Any]] | None = None) @@ -138,11 +138,11 @@ lang_chain_content_text(content: Any) -> str lang_chain_finish_reasons(source: Any) -> list[str] | None lang_chain_span_messages(messages: list[Any]) -> tuple[str | None, list[SpanMessage]] lang_chain_span_usage(usage: dict[str, Any] | None) -> SpanUsage | None -LDClientInterface [class] - close() -> None [async] - flush() -> None [async] - track(event_name: str, context: LDContext, data: Any = None, metric_value: float | None = None) -> None - variation(key: str, context: LDContext, default_value: Any) -> Any [async] +LDClientInterface(Protocol) [class] + close() -> Any + flush() -> Any + track(event_name: str, context: Any, data: Any = None, metric_value: float | None = None, /) -> Any + variation(key: str, context: Any, default_value: Any, /) -> Any LDContext = dict[str, Any] make_track_data(node: GraphNode, graph_key: str, run_id: str) -> dict[str, Any] MANIFEST_FILENAME = '.launchdarkly-skills.json' @@ -152,6 +152,8 @@ Message [class] role: Literal['user', 'assistant', 'system'] model_stamps_from_meta(meta: Any) -> dict[str, Any] NATIVE_TOOL_KEY: str = '__ld_native_tool__' +NativeGraphRunner(Protocol) [class] + invoke(input_text: str = '', variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None) -> Awaitable[dict[str, Any]] NativeTool [class] __init__(tool_name: str) id: object = object() @@ -180,11 +182,11 @@ ProviderGraphResponse [class, @dataclass] usage: UsageDict ProviderHandler [class] __init__(fn: _HandlerFn, provides_for: tuple[str, Literal['agent', 'messages']] | None = None, stream_fn: _StreamFn | None = None, capture_content: bool | None = None) - __call__(config: AiConfigRep, user_input: str | None = None, tool_handlers: dict[str, Any] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None) -> HandlerResult [async] + __call__(config: AiConfigRep, user_input: str | None = None, tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None) -> HandlerResult [async] capture_content: bool [property] has_stream: bool [property] provides_for: tuple[str, Literal['agent', 'messages']] | None = provides_for - stream(config: AiConfigRep, user_input: str | None = None, tool_handlers: dict[str, Any] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None) -> AsyncGenerator[dict[str, Any], None] [async] + stream(config: AiConfigRep, user_input: str | None = None, tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None) -> AsyncGenerator[dict[str, Any], None] [async] ProviderResponse(Generic[T]) [class, @dataclass] __init__(response: T, usage: UsageDict, judge_results: dict[str, JudgeResult] | None = None, judge_tasks: list[JudgeTask] | None = None, track_data: TrackData | None = None) judge_results: dict[str, JudgeResult] | None = None diff --git a/packages/client/pyproject.toml b/packages/client/pyproject.toml index 0cd975ea..32f4ef53 100644 --- a/packages/client/pyproject.toml +++ b/packages/client/pyproject.toml @@ -9,7 +9,7 @@ license = "Apache-2.0" authors = [{name = "LaunchDarkly", email = "team@launchdarkly.com"}] keywords = ["launchdarkly", "ai", "feature-flags", "sdk", "opentelemetry"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", @@ -27,6 +27,8 @@ otel = [ "opentelemetry-sdk>=1.25", "opentelemetry-exporter-otlp-proto-http>=1.25", ] +# The base SDK init_client() discovers at runtime; optional so BYOC installs stay lean. +ld = ["launchdarkly-server-sdk>=9.18.3,<10"] [build-system] requires = ["hatchling>=1.32,<2"] diff --git a/packages/client/src/launchdarkly_ai_server/__init__.py b/packages/client/src/launchdarkly_ai_server/__init__.py index edd26b63..4764e1b4 100644 --- a/packages/client/src/launchdarkly_ai_server/__init__.py +++ b/packages/client/src/launchdarkly_ai_server/__init__.py @@ -97,6 +97,7 @@ LDClientInterface, LDContext, Message, + NativeGraphRunner, NativeTool, ParseFailure, ParseResult, @@ -172,6 +173,7 @@ "ParseFailure", "ParseResult", "ParseSuccess", + "NativeGraphRunner", "ProviderGraphResponse", "ProviderHandler", "ProviderResponse", diff --git a/packages/client/src/launchdarkly_ai_server/lifecycle.py b/packages/client/src/launchdarkly_ai_server/lifecycle.py index 38e6f1f1..547a4380 100644 --- a/packages/client/src/launchdarkly_ai_server/lifecycle.py +++ b/packages/client/src/launchdarkly_ai_server/lifecycle.py @@ -10,7 +10,7 @@ from .sdk_info import flush_ai_sdk_info, reset_ai_sdk_info from .sdk_usage import flush_sdk_usage, report_usage, reset_sdk_usage from .shutdown_hooks import run_shutdown_hooks -from .types import InitClientOptions, SDKOptions +from .types import InitClientOptions, LDClientInterface, SDKOptions from .utils import model_stamps_from_meta logger = logging.getLogger(__name__) @@ -37,7 +37,7 @@ def _env(name: str) -> str | None: return value if value else None -_client: Any = None +_client: LDClientInterface | None = None _tracer_provider: Any = None # True only when *this* SDK's call to trace.set_tracer_provider actually took # effect. "We built a provider" is not the same as "we own the global": the set @@ -96,7 +96,7 @@ def note_ai_invocation() -> None: _ai_invoked = True -def get_client() -> Any: +def get_client() -> LDClientInterface: """ Returns the singleton LaunchDarkly client. Raises ``RuntimeError`` if ``init_client`` has not been called yet. @@ -208,8 +208,8 @@ def _setup_telemetry(sdk_key: str, options: InitClientOptions | None = None) -> async def init_client( options: InitClientOptions | None = None, - client: Any = None, -) -> Any: + client: LDClientInterface | None = None, +) -> LDClientInterface: """ Initializes the singleton LaunchDarkly client. @@ -278,7 +278,8 @@ async def init_client( except ImportError: raise RuntimeError( "LaunchDarkly server SDK not installed. " - "Run `pip install launchdarkly-server-sdk` or pass a pre-initialized client." + 'Run `pip install "launchdarkly-ai-server[ld]"` (installs launchdarkly-server-sdk) ' + "or pass a pre-initialized client." ) from None # Initialize LD client — Config wraps the SDK key and URI overrides; LDClient takes a Config. diff --git a/packages/client/src/launchdarkly_ai_server/tracking.py b/packages/client/src/launchdarkly_ai_server/tracking.py index acc4f8b5..418e8156 100644 --- a/packages/client/src/launchdarkly_ai_server/tracking.py +++ b/packages/client/src/launchdarkly_ai_server/tracking.py @@ -202,10 +202,13 @@ async def execute_and_track( # with an empty result. One recorder per invocation; they run concurrently. exposed_tool_keys = _exposed_tool_keys(config, tool_handlers) recorder = TrajectoryRecorder() - tracked_tool_handlers = wrap_tool_handlers( - recorder.wrap(tool_handlers or {}, exposed=exposed_tool_keys), - ld_ctx, - track_data, + # Copied into the handler contract's type: dict is invariant in its values. + tracked_tool_handlers: dict[str, Callable[..., Any] | NativeTool] = dict( + wrap_tool_handlers( + recorder.wrap(tool_handlers or {}, exposed=exposed_tool_keys), + ld_ctx, + track_data, + ) ) merged_variables: dict[str, Any] = { **(variables or {}), @@ -302,10 +305,13 @@ async def execute_and_stream( exposed_tool_keys = _exposed_tool_keys(config, tool_handlers) recorder = TrajectoryRecorder() - tracked_tool_handlers = wrap_tool_handlers( - recorder.wrap(tool_handlers or {}, exposed=exposed_tool_keys), - ld_ctx, - track_data, + # Copied into the handler contract's type: dict is invariant in its values. + tracked_tool_handlers: dict[str, Callable[..., Any] | NativeTool] = dict( + wrap_tool_handlers( + recorder.wrap(tool_handlers or {}, exposed=exposed_tool_keys), + ld_ctx, + track_data, + ) ) merged_variables: dict[str, Any] = { **(variables or {}), diff --git a/packages/client/src/launchdarkly_ai_server/types.py b/packages/client/src/launchdarkly_ai_server/types.py index 80bf1119..7b1ea3df 100644 --- a/packages/client/src/launchdarkly_ai_server/types.py +++ b/packages/client/src/launchdarkly_ai_server/types.py @@ -2,7 +2,7 @@ from collections.abc import AsyncGenerator, Awaitable, Callable from dataclasses import dataclass, field -from typing import Any, Generic, Literal, TypeVar +from typing import Any, Generic, Literal, Protocol, TypeVar # --------------------------------------------------------------------------- # LDContext @@ -19,24 +19,30 @@ # --------------------------------------------------------------------------- -class LDClientInterface: - """Protocol-like base; pass any object that satisfies this surface.""" +class LDClientInterface(Protocol): + """ + Structural type for any LaunchDarkly client; ``ldclient.LDClient`` satisfies it. - async def variation( - self, key: str, context: LDContext, default_value: Any - ) -> Any: ... + Methods may be sync or async: the SDK awaits any awaitable result. ``context`` is + ``Any`` because ``ldclient`` takes an ``ldclient.Context``, which the SDK builds from + an ``LDContext`` before the call. Parameters are positional-only so implementations + may name them freely (``ldclient`` calls the third ``variation`` argument ``default``). + """ + + def variation(self, key: str, context: Any, default_value: Any, /) -> Any: ... def track( self, event_name: str, - context: LDContext, + context: Any, data: Any = None, metric_value: float | None = None, - ) -> None: ... + /, + ) -> Any: ... - async def flush(self) -> None: ... + def flush(self) -> Any: ... - async def close(self) -> None: ... + def close(self) -> Any: ... # --------------------------------------------------------------------------- @@ -130,7 +136,7 @@ class HandlerStreamEvent: [ AiConfigRep, str | None, - dict[str, Any] | None, + dict[str, Callable[..., Any] | NativeTool] | None, dict[str, Any] | None, list[dict[str, Any]] | None, ], @@ -140,7 +146,7 @@ class HandlerStreamEvent: [ AiConfigRep, str | None, - dict[str, Any] | None, + dict[str, Callable[..., Any] | NativeTool] | None, dict[str, Any] | None, list[dict[str, Any]] | None, ], @@ -198,7 +204,7 @@ async def __call__( self, config: AiConfigRep, user_input: str | None = None, - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> HandlerResult: @@ -211,7 +217,7 @@ async def stream( self, config: AiConfigRep, user_input: str | None = None, - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: @@ -489,6 +495,21 @@ class ProviderGraphResponse: """Results from a graph-level judge, if configured.""" +class NativeGraphRunner(Protocol): + """ + What a native graph adapter (``to_lang_graph``, ``to_openai_agents``, …) returns. + + ``invoke`` resolves to ``{"response": str, "usage": UsageDict}``. + """ + + def invoke( + self, + input_text: str = "", + variables: dict[str, Any] | None = None, + history: list[dict[str, Any]] | None = None, + ) -> Awaitable[dict[str, Any]]: ... + + # --------------------------------------------------------------------------- # Agent Skills # --------------------------------------------------------------------------- diff --git a/packages/client/tests/test_public_surface.py b/packages/client/tests/test_public_surface.py index ec8972e5..9bba7e55 100644 --- a/packages/client/tests/test_public_surface.py +++ b/packages/client/tests/test_public_surface.py @@ -40,6 +40,7 @@ "ParseFailure", "ParseResult", "ParseSuccess", + "NativeGraphRunner", "ProviderGraphResponse", "ProviderHandler", "ProviderResponse", diff --git a/packages/langchain-agents/agents.md b/packages/langchain-agents/agents.md index 2e4187ec..6160dd59 100644 --- a/packages/langchain-agents/agents.md +++ b/packages/langchain-agents/agents.md @@ -51,7 +51,7 @@ def langchain_agents(config_key: str, user_input: str, context: dict, **kwargs) def langchain_graph(key: str, options: dict | None = None, llm: BaseChatModel | None = None): ... # Native graph adapter — builds a LangGraph StateGraph from a resolved GraphDefinition -def to_lang_graph(def_promise: Awaitable[dict], opts: dict | None = None): ... +def to_lang_graph(def_promise: Awaitable[GraphDefinition], opts: LangChainNativeGraphOptions | None = None) -> NativeGraphRunner: ... ``` --- diff --git a/packages/langchain-agents/api-report/launchdarkly_ai_langchain_agents.txt b/packages/langchain-agents/api-report/launchdarkly_ai_langchain_agents.txt index 4a23ab21..d01dc20e 100644 --- a/packages/langchain-agents/api-report/launchdarkly_ai_langchain_agents.txt +++ b/packages/langchain-agents/api-report/launchdarkly_ai_langchain_agents.txt @@ -2,4 +2,8 @@ create_langchain_agents_handler(llm: Any = None, *, capture_content: bool | None = None) -> ProviderHandler langchain_agents(config_key: str, user_input: str, context: LDContext, **kwargs: Any) -> Any langchain_graph(key: str, llm: Any = None, **options: Any) -> Any -to_lang_graph(def_promise: Any, opts: dict[str, Any] | None = None) -> Any +LangChainNativeGraphOptions(TypedDict, total=False) [class] + context: LDContext + model_factory: Callable[[GraphNode], Any] + tool_handlers: dict[str, Callable[..., Any] | NativeTool] +to_lang_graph(def_promise: Awaitable[GraphDefinition], opts: LangChainNativeGraphOptions | None = None) -> NativeGraphRunner diff --git a/packages/langchain-agents/pyproject.toml b/packages/langchain-agents/pyproject.toml index 5b6d31fe..511179f6 100644 --- a/packages/langchain-agents/pyproject.toml +++ b/packages/langchain-agents/pyproject.toml @@ -5,8 +5,8 @@ requires-python = ">=3.12" dependencies = [ "launchdarkly-ai-server>=0.4.1", "opentelemetry-api>=1.25", - "langchain-core>=0.3", - "langgraph>=0.2", + "langchain-core>=1.6", + "langgraph>=1.2", ] description = "LangChain/LangGraph agent handler for LaunchDarkly AI SDK" readme = "README.md" @@ -14,7 +14,7 @@ license = "Apache-2.0" authors = [{name = "LaunchDarkly", email = "team@launchdarkly.com"}] keywords = ["launchdarkly", "ai", "langchain", "langgraph", "agents"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/__init__.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/__init__.py index a38273bc..192f9bf7 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/__init__.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/__init__.py @@ -8,9 +8,10 @@ from ._version import PACKAGE_NAME, __version__ from .graph import langchain_graph from .handler import create_langchain_agents_handler, langchain_agents -from .native_graph import to_lang_graph +from .native_graph import LangChainNativeGraphOptions, to_lang_graph __all__ = [ + "LangChainNativeGraphOptions", "create_langchain_agents_handler", "langchain_agents", "langchain_graph", diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py index 5346a412..be97f3fd 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py @@ -8,12 +8,13 @@ import asyncio import inspect import json -from collections.abc import AsyncGenerator, Mapping +from collections.abc import AsyncGenerator, Callable, Mapping from typing import Any from launchdarkly_ai_server import ( AiConfigRep, LDContext, + NativeTool, ProviderHandler, SpanMessage, SpanMessagePart, @@ -390,7 +391,7 @@ def _rebuild_anthropic_output_config(kwargs: dict[str, Any]) -> dict[str, Any]: def _build_agent_tools( config_tools: dict[str, Any], - tool_handlers: dict[str, Any], + tool_handlers: dict[str, Callable[..., Any] | NativeTool], ) -> list[Any]: import importlib @@ -403,7 +404,8 @@ def _build_agent_tools( async def _handler(_name: str = name, **kwargs: Any) -> str: fn = tool_handlers.get(_name) - if not fn: + # A NativeTool reaches here unwrapped on the offline evaluations path. + if not callable(fn): raise ValueError(f'No handler registered for tool "{_name}"') # Handlers may be sync or async. Graph ``__handoff_*`` tools stay sync so # routing records the selected edge on the call itself; awaiting a plain @@ -660,7 +662,7 @@ def _create_langchain_agents_handler( async def _call_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: @@ -791,7 +793,7 @@ async def _call_impl( def _stream_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: @@ -817,7 +819,7 @@ async def _stream_gen( llm: Any, config: AiConfigRep, user_input: str, - tool_handlers: dict[str, Any], + tool_handlers: dict[str, Callable[..., Any] | NativeTool], variables: dict[str, Any], history: list[dict[str, Any]] | None = None, *, diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py index a9ddade0..56f131b2 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py @@ -8,13 +8,15 @@ import inspect import re import time -import types import uuid +from collections.abc import Awaitable, Callable from typing import Annotated, Any, TypedDict from launchdarkly_ai_server import ( GraphDefinition, GraphNode, + LDContext, + NativeGraphRunner, NativeTool, compose_history, get_client, @@ -69,7 +71,7 @@ def _build_system_prompt(node: GraphNode, variables: dict[str, Any]) -> str | No def _build_node_tools( node: GraphNode, - tool_handlers: dict[str, Any], + tool_handlers: dict[str, Callable[..., Any] | NativeTool], ) -> list[Any]: import importlib @@ -121,10 +123,20 @@ def _extract_usage(msg: Any) -> dict[str, int]: return {"input": input_t, "output": output_t, "total": input_t + output_t} +class LangChainNativeGraphOptions(TypedDict, total=False): + """Options for :func:`to_lang_graph`.""" + + tool_handlers: dict[str, Callable[..., Any] | NativeTool] + model_factory: Callable[[GraphNode], Any] + """Builds the chat model for a node. Defaults to ``ChatOpenAI``.""" + context: LDContext + """LaunchDarkly context used for tracking events. Required for LD telemetry.""" + + def to_lang_graph( - def_promise: Any, - opts: dict[str, Any] | None = None, -) -> Any: + def_promise: Awaitable[GraphDefinition], + opts: LangChainNativeGraphOptions | None = None, +) -> NativeGraphRunner: """ Converts a resolved ``GraphDefinition`` into a compiled LangGraph ``StateGraph`` and returns a caller that runs it via ``compiled.invoke``. @@ -171,7 +183,9 @@ async def invoke( if not root: raise ValueError(f'Graph "{def_obj.key}" has no root node') - tool_handlers: dict[str, Any] = _opts.get("tool_handlers") or {} + tool_handlers: dict[str, Callable[..., Any] | NativeTool] = ( + _opts.get("tool_handlers") or {} + ) model_factory = _opts.get("model_factory") raw_ld_context = _opts.get("context") ld_context = ( @@ -464,4 +478,10 @@ def _content_str(msg: Any) -> str: if span: span.end() - return types.SimpleNamespace(invoke=invoke) + # A class, not a SimpleNamespace, so mypy checks invoke against NativeGraphRunner. + _invoke = invoke + + class _Runner: + invoke = staticmethod(_invoke) + + return _Runner() diff --git a/packages/langchain-agents/tests/test_handler.py b/packages/langchain-agents/tests/test_handler.py index a68c42a4..b52bc2f7 100644 --- a/packages/langchain-agents/tests/test_handler.py +++ b/packages/langchain-agents/tests/test_handler.py @@ -452,6 +452,18 @@ def _capture_tool(name: Any, fn: Any = None, **kw: Any) -> Any: with pytest.raises(ValueError, match="No handler"): await captured_fns[0](key="val") + @pytest.mark.asyncio + async def test_native_tool_is_not_called(self) -> None: + # Offline evaluations pass a NativeTool through unwrapped; it is not callable. + from launchdarkly_ai_server import NativeTool + + run = _built_tool( + {"web-search": {"description": "d", "parameters": {}}}, + {"web-search": NativeTool("WebSearch")}, + ) + with pytest.raises(ValueError, match="No handler"): + await run(query="q") + @pytest.mark.asyncio async def test_tool_handler_throws_propagates(self) -> None: mocks = _make_langchain_mock() diff --git a/packages/langchain-messages/pyproject.toml b/packages/langchain-messages/pyproject.toml index 281ad43f..2c5aff92 100644 --- a/packages/langchain-messages/pyproject.toml +++ b/packages/langchain-messages/pyproject.toml @@ -5,7 +5,7 @@ requires-python = ">=3.12" dependencies = [ "launchdarkly-ai-server>=0.4.1", "opentelemetry-api>=1.25", - "langchain-core>=0.3", + "langchain-core>=1.6", ] description = "LangChain messages handler for LaunchDarkly AI SDK" readme = "README.md" @@ -13,7 +13,7 @@ license = "Apache-2.0" authors = [{name = "LaunchDarkly", email = "team@launchdarkly.com"}] keywords = ["launchdarkly", "ai", "langchain"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", diff --git a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py index 78f36622..9af2898d 100644 --- a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py +++ b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py @@ -2,13 +2,14 @@ import asyncio import json -from collections.abc import AsyncGenerator, Mapping +from collections.abc import AsyncGenerator, Callable, Mapping from types import SimpleNamespace from typing import Any from launchdarkly_ai_server import ( AiConfigRep, LDContext, + NativeTool, ProviderHandler, SpanMessage, SpanMessagePart, @@ -791,7 +792,7 @@ def _create_langchain_messages_handler( async def _call_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: @@ -1085,7 +1086,7 @@ async def _call_impl( def _stream_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: @@ -1111,7 +1112,7 @@ async def _stream_gen( llm: Any, config: AiConfigRep, user_input: str, - tool_handlers: dict[str, Any], + tool_handlers: dict[str, Callable[..., Any] | NativeTool], variables: dict[str, Any], history: list[dict[str, Any]] | None = None, *, diff --git a/packages/litellm-agents/README.md b/packages/litellm-agents/README.md index a76b2d68..ce8f0ebf 100644 --- a/packages/litellm-agents/README.md +++ b/packages/litellm-agents/README.md @@ -8,7 +8,7 @@ Agents SDK's `LitellmModel`. ## Installation ```bash -pip install launchdarkly-ai-server launchdarkly-server-sdk launchdarkly-ai-litellm-agents +pip install "launchdarkly-ai-server[ld]" launchdarkly-ai-litellm-agents ``` Python uses LiteLLM in-process; no proxy endpoint is required. Set the provider diff --git a/packages/litellm-agents/api-report/launchdarkly_ai_litellm_agents.txt b/packages/litellm-agents/api-report/launchdarkly_ai_litellm_agents.txt index b08d845e..a8d2a04a 100644 --- a/packages/litellm-agents/api-report/launchdarkly_ai_litellm_agents.txt +++ b/packages/litellm-agents/api-report/launchdarkly_ai_litellm_agents.txt @@ -2,4 +2,10 @@ create_litellm_agents_handler(*, model_factory: ModelFactory | None = None, capture_content: bool | None = None, base_url: str | None = None, api_key: str | None = None) -> ProviderHandler litellm_agents(config_key: str, user_input: str, context: LDContext, **kwargs: Any) -> Any litellm_graph(key: str, *, model_factory: ModelFactory | None = None, capture_content: bool | None = None, base_url: str | None = None, api_key: str | None = None, **options: Any) -> Any -to_litellm_agents(definition: Any, opts: dict[str, Any] | None = None) -> Any +LiteLLMNativeGraphOptions(TypedDict, total=False) [class] + api_key: str + base_url: str + context: LDContext + model_factory: ModelFactory + tool_handlers: dict[str, Callable[..., Any] | NativeTool] +to_litellm_agents(definition: Awaitable[GraphDefinition], opts: LiteLLMNativeGraphOptions | None = None) -> NativeGraphRunner diff --git a/packages/litellm-agents/pyproject.toml b/packages/litellm-agents/pyproject.toml index 39394082..955407b3 100644 --- a/packages/litellm-agents/pyproject.toml +++ b/packages/litellm-agents/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.1" requires-python = ">=3.12" dependencies = [ "launchdarkly-ai-server>=0.4.1", - "openai-agents[litellm]>=0.2.11", + "openai-agents[litellm]>=0.17", "opentelemetry-api>=1.25", ] description = "LiteLLM-backed OpenAI Agents handler for LaunchDarkly AI SDK" @@ -13,7 +13,7 @@ license = "Apache-2.0" authors = [{name = "LaunchDarkly", email = "team@launchdarkly.com"}] keywords = ["launchdarkly", "ai", "litellm", "agents"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", diff --git a/packages/litellm-agents/src/launchdarkly_ai_litellm_agents/__init__.py b/packages/litellm-agents/src/launchdarkly_ai_litellm_agents/__init__.py index e086d01e..71ddb5ac 100644 --- a/packages/litellm-agents/src/launchdarkly_ai_litellm_agents/__init__.py +++ b/packages/litellm-agents/src/launchdarkly_ai_litellm_agents/__init__.py @@ -5,9 +5,10 @@ from ._version import PACKAGE_NAME, __version__ from .graph import litellm_graph from .handler import create_litellm_agents_handler, litellm_agents -from .native_graph import to_litellm_agents +from .native_graph import LiteLLMNativeGraphOptions, to_litellm_agents __all__ = [ + "LiteLLMNativeGraphOptions", "create_litellm_agents_handler", "litellm_agents", "litellm_graph", diff --git a/packages/litellm-agents/src/launchdarkly_ai_litellm_agents/handler.py b/packages/litellm-agents/src/launchdarkly_ai_litellm_agents/handler.py index 065a1e69..c14c86a9 100644 --- a/packages/litellm-agents/src/launchdarkly_ai_litellm_agents/handler.py +++ b/packages/litellm-agents/src/launchdarkly_ai_litellm_agents/handler.py @@ -18,6 +18,7 @@ from launchdarkly_ai_server import ( AiConfigRep, LDContext, + NativeTool, ProviderHandler, SpanUsage, _config, @@ -572,7 +573,7 @@ def model_factory(name: str, _parameters: dict[str, Any]) -> Any: async def call( config_value: AiConfigRep, user_input: str | None = None, - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: @@ -638,7 +639,7 @@ async def call( def stream( config_value: AiConfigRep, user_input: str | None = None, - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: diff --git a/packages/litellm-agents/src/launchdarkly_ai_litellm_agents/native_graph.py b/packages/litellm-agents/src/launchdarkly_ai_litellm_agents/native_graph.py index ce091168..55cd79e5 100644 --- a/packages/litellm-agents/src/launchdarkly_ai_litellm_agents/native_graph.py +++ b/packages/litellm-agents/src/launchdarkly_ai_litellm_agents/native_graph.py @@ -8,13 +8,17 @@ import time import types import uuid -from typing import Any +from collections.abc import Awaitable, Callable +from typing import Any, TypedDict from agents import RunConfig from launchdarkly_ai_server import ( GraphDefinition, GraphNode, + LDContext, + NativeGraphRunner, + NativeTool, compose_history, get_client, make_track_data, @@ -64,10 +68,22 @@ def _instructions(node: GraphNode, variables: dict[str, Any]) -> str | None: ) +class LiteLLMNativeGraphOptions(TypedDict, total=False): + """Options for :func:`to_litellm_agents`.""" + + tool_handlers: dict[str, Callable[..., Any] | NativeTool] + context: LDContext + """LaunchDarkly context used for tracking events. Required for LD telemetry.""" + model_factory: ModelFactory + """Builds the model for a node from its name and parameters. Defaults to ``LitellmModel``.""" + base_url: str + api_key: str + + def to_litellm_agents( - definition: Any, - opts: dict[str, Any] | None = None, -) -> Any: + definition: Awaitable[GraphDefinition], + opts: LiteLLMNativeGraphOptions | None = None, +) -> NativeGraphRunner: report_usage("litellm-agents.toLiteLLMAgents", PACKAGE_NAME, __version__) options = opts or {} @@ -311,4 +327,10 @@ async def on_agent_start(self, context: Any, agent: Any) -> None: if span: span.end() - return types.SimpleNamespace(invoke=invoke) + # A class, not a SimpleNamespace, so mypy checks invoke against NativeGraphRunner. + _invoke = invoke + + class _Runner: + invoke = staticmethod(_invoke) + + return _Runner() diff --git a/packages/litellm-messages/README.md b/packages/litellm-messages/README.md index 1616a092..e38b07c3 100644 --- a/packages/litellm-messages/README.md +++ b/packages/litellm-messages/README.md @@ -8,7 +8,7 @@ Provider-agnostic LaunchDarkly AI Config handler backed by LiteLLM's in-process ## Installation ```bash -pip install launchdarkly-ai-server launchdarkly-server-sdk launchdarkly-ai-litellm-messages +pip install "launchdarkly-ai-server[ld]" launchdarkly-ai-litellm-messages ``` Python uses LiteLLM in-process; no proxy endpoint is required. Set the provider diff --git a/packages/litellm-messages/pyproject.toml b/packages/litellm-messages/pyproject.toml index e89008f0..ca5ccc16 100644 --- a/packages/litellm-messages/pyproject.toml +++ b/packages/litellm-messages/pyproject.toml @@ -13,7 +13,7 @@ license = "Apache-2.0" authors = [{name = "LaunchDarkly", email = "team@launchdarkly.com"}] keywords = ["launchdarkly", "ai", "litellm"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", diff --git a/packages/litellm-messages/src/launchdarkly_ai_litellm_messages/handler.py b/packages/litellm-messages/src/launchdarkly_ai_litellm_messages/handler.py index 6ded1a1b..e3a386d0 100644 --- a/packages/litellm-messages/src/launchdarkly_ai_litellm_messages/handler.py +++ b/packages/litellm-messages/src/launchdarkly_ai_litellm_messages/handler.py @@ -12,6 +12,7 @@ from launchdarkly_ai_server import ( AiConfigRep, LDContext, + NativeTool, ProviderHandler, SpanUsage, _config, @@ -205,7 +206,7 @@ def _build_messages( def _build_tools( - config_value: AiConfigRep, tool_handlers: dict[str, Any] + config_value: AiConfigRep, tool_handlers: dict[str, Callable[..., Any] | NativeTool] ) -> list[dict[str, Any]]: return [ { @@ -455,7 +456,7 @@ def _create_litellm_messages_handler( async def call( config_value: AiConfigRep, user_input: str | None = None, - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: @@ -567,7 +568,7 @@ async def call( def stream( config_value: AiConfigRep, user_input: str | None = None, - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: diff --git a/packages/openai-agents/agents.md b/packages/openai-agents/agents.md index 3393c4a4..c5643387 100644 --- a/packages/openai-agents/agents.md +++ b/packages/openai-agents/agents.md @@ -48,7 +48,7 @@ def openai_agents(config_key: str, user_input: str, context: dict, **kwargs) -> def openai_graph(key: str, options: dict | None = None): ... # Native graph adapter -def to_openai_agents(def_promise: Awaitable[dict], opts: dict | None = None): ... +def to_openai_agents(def_promise: Awaitable[GraphDefinition], opts: OpenAINativeGraphOptions | None = None) -> NativeGraphRunner: ... ``` --- diff --git a/packages/openai-agents/api-report/launchdarkly_ai_openai_agents.txt b/packages/openai-agents/api-report/launchdarkly_ai_openai_agents.txt index 6b977819..46f34a80 100644 --- a/packages/openai-agents/api-report/launchdarkly_ai_openai_agents.txt +++ b/packages/openai-agents/api-report/launchdarkly_ai_openai_agents.txt @@ -3,4 +3,7 @@ build_output_type(output_format: dict[str, Any] | None) -> dict[str, Any] | None create_openai_agent_handler(*, capture_content: bool | None = None) -> ProviderHandler openai_agents(config_key: str, user_input: str, context: LDContext, **kwargs: Any) -> Any openai_graph(key: str, **options: Any) -> Any -to_openai_agents(def_promise: Any, opts: dict[str, Any] | None = None) -> Any +OpenAINativeGraphOptions(TypedDict, total=False) [class] + context: LDContext + tool_handlers: dict[str, Callable[..., Any] | NativeTool] +to_openai_agents(def_promise: Awaitable[GraphDefinition], opts: OpenAINativeGraphOptions | None = None) -> NativeGraphRunner diff --git a/packages/openai-agents/pyproject.toml b/packages/openai-agents/pyproject.toml index 0dcf72ac..6030f37f 100644 --- a/packages/openai-agents/pyproject.toml +++ b/packages/openai-agents/pyproject.toml @@ -5,8 +5,8 @@ requires-python = ">=3.12" dependencies = [ "launchdarkly-ai-server>=0.4.1", "opentelemetry-api>=1.25", - "openai>=1.0", - "openai-agents>=0.0.1", + "openai>=2.44", + "openai-agents>=0.17", ] description = "OpenAI agent handler for LaunchDarkly AI SDK" readme = "README.md" @@ -14,7 +14,7 @@ license = "Apache-2.0" authors = [{name = "LaunchDarkly", email = "team@launchdarkly.com"}] keywords = ["launchdarkly", "ai", "openai", "agents"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/__init__.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/__init__.py index 0475189c..684c32a6 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/__init__.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/__init__.py @@ -9,10 +9,11 @@ from ._version import PACKAGE_NAME, __version__ from .graph import openai_graph from .handler import create_openai_agent_handler, openai_agents -from .native_graph import to_openai_agents +from .native_graph import OpenAINativeGraphOptions, to_openai_agents from .utils import build_output_type __all__ = [ + "OpenAINativeGraphOptions", "build_output_type", "create_openai_agent_handler", "openai_agents", diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py index b0fe0942..02f08bf2 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py @@ -21,12 +21,13 @@ import asyncio import inspect import json -from collections.abc import AsyncGenerator, Mapping +from collections.abc import AsyncGenerator, Callable, Mapping from typing import Any from launchdarkly_ai_server import ( AiConfigRep, LDContext, + NativeTool, ProviderHandler, RunUsage, SpanUsage, @@ -206,7 +207,7 @@ def _model_settings_parameters(config: AiConfigRep) -> dict[str, Any]: def _build_agent_tools( config_tools: dict[str, Any], - tool_handlers: dict[str, Any], + tool_handlers: dict[str, Callable[..., Any] | NativeTool], ) -> list[Any]: # Not filtered to the tools that have a registered handler, unlike the TypeScript SDK, which # excludes an unregistered tool from the catalog entirely. This difference predates the span @@ -284,7 +285,7 @@ def _prompt_to_text(prompt: str | list[dict[str, Any]]) -> str: def _build_agent_and_prompt( config: AiConfigRep, user_input: str | None, - tool_handlers: dict[str, Any], + tool_handlers: dict[str, Callable[..., Any] | NativeTool], variables: dict[str, Any], history: list[dict[str, Any]] | None = None, ) -> tuple[Any, str | list[dict[str, Any]], str | None]: @@ -670,7 +671,7 @@ def _create_openai_agent_handler( async def _call_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: @@ -753,7 +754,7 @@ async def _call_impl( def _stream_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: @@ -788,7 +789,7 @@ def _stringify_output(value: Any) -> str: async def _stream_gen( config: AiConfigRep, user_input: str, - tool_handlers: dict[str, Any], + tool_handlers: dict[str, Callable[..., Any] | NativeTool], variables: dict[str, Any], history: list[dict[str, Any]] | None = None, *, diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py index 51ed6436..cf3643c2 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py @@ -9,13 +9,15 @@ import logging import re import time -import types import uuid -from typing import Any +from collections.abc import Awaitable, Callable +from typing import Any, TypedDict from launchdarkly_ai_server import ( GraphDefinition, GraphNode, + LDContext, + NativeGraphRunner, NativeTool, compose_history, get_client, @@ -63,7 +65,7 @@ def _build_instructions(node: GraphNode, variables: dict[str, Any]) -> str | Non def _build_node_tools( node: GraphNode, - tool_handlers: dict[str, Any], + tool_handlers: dict[str, Callable[..., Any] | NativeTool], ) -> list[Any]: import importlib @@ -96,10 +98,18 @@ async def _execute(args: Any, _name: str = name) -> str: return result +class OpenAINativeGraphOptions(TypedDict, total=False): + """Options for :func:`to_openai_agents`.""" + + tool_handlers: dict[str, Callable[..., Any] | NativeTool] + context: LDContext + """LaunchDarkly context used for tracking events. Required for LD telemetry.""" + + def to_openai_agents( - def_promise: Any, - opts: dict[str, Any] | None = None, -) -> Any: + def_promise: Awaitable[GraphDefinition], + opts: OpenAINativeGraphOptions | None = None, +) -> NativeGraphRunner: """ Converts a resolved ``GraphDefinition`` into an OpenAI Agents SDK agent tree and returns a caller that runs the graph natively via ``Runner.run``. @@ -139,7 +149,9 @@ async def invoke( if not root: raise ValueError(f'Graph "{def_obj.key}" has no root node') - tool_handlers: dict[str, Any] = _opts.get("tool_handlers") or {} + tool_handlers: dict[str, Callable[..., Any] | NativeTool] = ( + _opts.get("tool_handlers") or {} + ) raw_ld_context = _opts.get("context") ld_context = ( to_ld_context(get_client(), raw_ld_context) @@ -374,4 +386,10 @@ async def on_agent_start(self, context: Any, agent: Any) -> None: if span: span.end() - return types.SimpleNamespace(invoke=invoke) + # A class, not a SimpleNamespace, so mypy checks invoke against NativeGraphRunner. + _invoke = invoke + + class _Runner: + invoke = staticmethod(_invoke) + + return _Runner() diff --git a/packages/openai-messages/pyproject.toml b/packages/openai-messages/pyproject.toml index fea4d5b5..25ed82e2 100644 --- a/packages/openai-messages/pyproject.toml +++ b/packages/openai-messages/pyproject.toml @@ -5,7 +5,7 @@ requires-python = ">=3.12" dependencies = [ "launchdarkly-ai-server>=0.4.1", "opentelemetry-api>=1.25", - "openai>=1.0", + "openai>=2.44", ] description = "OpenAI messages handler for LaunchDarkly AI SDK" readme = "README.md" @@ -13,7 +13,7 @@ license = "Apache-2.0" authors = [{name = "LaunchDarkly", email = "team@launchdarkly.com"}] keywords = ["launchdarkly", "ai", "openai"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", diff --git a/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py b/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py index 5e804e41..bad7fe66 100644 --- a/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py +++ b/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py @@ -2,12 +2,13 @@ import asyncio import json -from collections.abc import AsyncGenerator, Mapping +from collections.abc import AsyncGenerator, Callable, Mapping from typing import Any from launchdarkly_ai_server import ( AiConfigRep, LDContext, + NativeTool, ProviderHandler, RunUsage, SpanMessage, @@ -407,7 +408,7 @@ def _create_openai_messages_handler( async def _call_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: @@ -578,7 +579,7 @@ async def _call_impl( def _stream_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: @@ -612,7 +613,7 @@ async def _stream_gen( client: Any, config: AiConfigRep, user_input: str, - tool_handlers: dict[str, Any], + tool_handlers: dict[str, Callable[..., Any] | NativeTool], variables: dict[str, Any], history: list[dict[str, Any]] | None = None, *, diff --git a/packages/opencode-agents/README.md b/packages/opencode-agents/README.md index 9037e5d5..3b8148c1 100644 --- a/packages/opencode-agents/README.md +++ b/packages/opencode-agents/README.md @@ -5,7 +5,7 @@ OpenCode handler for `launchdarkly-ai-server`. `async with handler` starts `open **`provides_for`:** `['*', 'agent']` — matches an agent-mode variation for any provider. ```bash -pip install launchdarkly-ai-server launchdarkly-server-sdk launchdarkly-ai-opencode-agents +pip install "launchdarkly-ai-server[ld]" launchdarkly-ai-opencode-agents ``` ```python diff --git a/packages/opencode-agents/pyproject.toml b/packages/opencode-agents/pyproject.toml index ed0a1c7b..ea907f28 100644 --- a/packages/opencode-agents/pyproject.toml +++ b/packages/opencode-agents/pyproject.toml @@ -4,7 +4,7 @@ version = "0.2.0" requires-python = ">=3.12" dependencies = [ "launchdarkly-ai-server>=0.4.1", - "httpx>=0.27", + "httpx>=0.28", "opentelemetry-api>=1.25", ] description = "OpenCode agent handler for LaunchDarkly AI SDK" @@ -13,7 +13,7 @@ license = "Apache-2.0" authors = [{name = "LaunchDarkly", email = "team@launchdarkly.com"}] keywords = ["launchdarkly", "ai", "opencode", "agents"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index 6a2e9886..226bdb23 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -11,12 +11,13 @@ import asyncio import os -from collections.abc import AsyncGenerator, AsyncIterable, Mapping +from collections.abc import AsyncGenerator, AsyncIterable, Callable, Mapping from contextlib import aclosing from typing import Any from launchdarkly_ai_server import ( AiConfigRep, + NativeTool, ProviderHandler, SpanMessage, SpanUsage, @@ -642,7 +643,7 @@ async def _stream_impl( self, config: AiConfigRep, user_input: str | None, - tool_handlers: dict[str, Any] | None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None, variables: dict[str, Any] | None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: @@ -724,7 +725,7 @@ async def _call_impl( self, config: AiConfigRep, user_input: str | None, - tool_handlers: dict[str, Any] | None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None, variables: dict[str, Any] | None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: diff --git a/packages/typesafe/pyproject.toml b/packages/typesafe/pyproject.toml index 67c999b3..b4e76d35 100644 --- a/packages/typesafe/pyproject.toml +++ b/packages/typesafe/pyproject.toml @@ -5,7 +5,7 @@ requires-python = ">=3.12" dependencies = [ "launchdarkly-ai-server", "opentelemetry-api>=1.25", - "typesafe-sdk>=0.6", + "typesafe-sdk>=0.7", ] description = "TypeSafe Jev handler for LaunchDarkly AI SDK judges" readme = "README.md" @@ -13,7 +13,7 @@ license = "Apache-2.0" authors = [{name = "LaunchDarkly", email = "team@launchdarkly.com"}] keywords = ["launchdarkly", "ai", "typesafe", "jev"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", diff --git a/packages/typesafe/src/launchdarkly_ai_typesafe/handler.py b/packages/typesafe/src/launchdarkly_ai_typesafe/handler.py index 7509f9d7..c9fec1d3 100644 --- a/packages/typesafe/src/launchdarkly_ai_typesafe/handler.py +++ b/packages/typesafe/src/launchdarkly_ai_typesafe/handler.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from collections.abc import Mapping +from collections.abc import Callable, Mapping from typing import Any from opentelemetry import trace @@ -12,6 +12,7 @@ from launchdarkly_ai_server import ( AiConfigRep, + NativeTool, ProviderHandler, _create_handler, report_usage, @@ -98,7 +99,7 @@ def create_typesafe_handler(*, capture_content: bool | None = None) -> ProviderH async def _call( config: AiConfigRep, user_input: str | None = None, - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: diff --git a/packages/vercel-agents/README.md b/packages/vercel-agents/README.md index a689dec1..b38dcbda 100644 --- a/packages/vercel-agents/README.md +++ b/packages/vercel-agents/README.md @@ -6,7 +6,7 @@ runtime for Python. ## Install ```bash -pip install launchdarkly-ai-server launchdarkly-server-sdk launchdarkly-ai-vercel-agents +pip install "launchdarkly-ai-server[ld]" launchdarkly-ai-vercel-agents ``` Default routing is AI Gateway via a mapped `creator/model` id (for example, diff --git a/packages/vercel-agents/api-report/launchdarkly_ai_vercel_agents.txt b/packages/vercel-agents/api-report/launchdarkly_ai_vercel_agents.txt index 66471098..c1cd27de 100644 --- a/packages/vercel-agents/api-report/launchdarkly_ai_vercel_agents.txt +++ b/packages/vercel-agents/api-report/launchdarkly_ai_vercel_agents.txt @@ -1,5 +1,10 @@ # Public API of launchdarkly_ai_vercel_agents. Generated by `make api-report`; do not edit. create_vercel_agents_handler(model: ModelSource | None = None, *, capture_content: bool | None = None) -> ProviderHandler -to_vercel_agents(definition_promise: Any, opts: dict[str, Any] | None = None) -> Any +to_vercel_agents(definition_promise: Awaitable[GraphDefinition], opts: VercelNativeGraphOptions | None = None) -> NativeGraphRunner vercel_agents(config_key: str, user_input: str | None, context: LDContext, *, model: ModelSource | None = None, capture_content: bool | None = None, variables: dict[str, Any] | None = None, **options: Any) -> Any vercel_graph(key: str, model: ModelSource | None = None, *, capture_content: bool | None = None, **options: Any) -> Any +VercelNativeGraphOptions(TypedDict, total=False) [class] + context: LDContext + model: ModelSource + model_factory: ModelSource + tool_handlers: dict[str, Callable[..., Any] | NativeTool] diff --git a/packages/vercel-agents/pyproject.toml b/packages/vercel-agents/pyproject.toml index 63b84881..81ae3d3b 100644 --- a/packages/vercel-agents/pyproject.toml +++ b/packages/vercel-agents/pyproject.toml @@ -12,6 +12,14 @@ readme = "README.md" license = "Apache-2.0" authors = [{name = "LaunchDarkly", email = "team@launchdarkly.com"}] keywords = ["launchdarkly", "ai", "vercel", "agents"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries", +] [build-system] requires = ["hatchling>=1.32,<2"] diff --git a/packages/vercel-agents/src/launchdarkly_ai_vercel_agents/__init__.py b/packages/vercel-agents/src/launchdarkly_ai_vercel_agents/__init__.py index a5316dff..02aecdba 100644 --- a/packages/vercel-agents/src/launchdarkly_ai_vercel_agents/__init__.py +++ b/packages/vercel-agents/src/launchdarkly_ai_vercel_agents/__init__.py @@ -5,9 +5,10 @@ from ._version import PACKAGE_NAME, __version__ from .graph import vercel_graph from .handler import create_vercel_agents_handler, vercel_agents -from .native_graph import to_vercel_agents +from .native_graph import VercelNativeGraphOptions, to_vercel_agents __all__ = [ + "VercelNativeGraphOptions", "create_vercel_agents_handler", "to_vercel_agents", "vercel_agents", diff --git a/packages/vercel-agents/src/launchdarkly_ai_vercel_agents/handler.py b/packages/vercel-agents/src/launchdarkly_ai_vercel_agents/handler.py index a4b91379..4b8ef437 100644 --- a/packages/vercel-agents/src/launchdarkly_ai_vercel_agents/handler.py +++ b/packages/vercel-agents/src/launchdarkly_ai_vercel_agents/handler.py @@ -518,7 +518,7 @@ def _create_vercel_agents_handler( async def run( cfg: AiConfigRep, user_input: str | None = None, - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: @@ -571,7 +571,7 @@ async def run( async def stream( cfg: AiConfigRep, user_input: str | None = None, - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: diff --git a/packages/vercel-agents/src/launchdarkly_ai_vercel_agents/native_graph.py b/packages/vercel-agents/src/launchdarkly_ai_vercel_agents/native_graph.py index a40680d5..a497caec 100644 --- a/packages/vercel-agents/src/launchdarkly_ai_vercel_agents/native_graph.py +++ b/packages/vercel-agents/src/launchdarkly_ai_vercel_agents/native_graph.py @@ -2,8 +2,8 @@ import time import uuid -from types import SimpleNamespace -from typing import Any +from collections.abc import Awaitable, Callable +from typing import Any, TypedDict import ai from ai.types.tools import ToolSpec @@ -13,6 +13,9 @@ from launchdarkly_ai_server import ( GraphDefinition, GraphNode, + LDContext, + NativeGraphRunner, + NativeTool, get_client, make_track_data, report_usage, @@ -80,10 +83,22 @@ def _track( client.track(name, to_ld_context(client, context), data, value) +class VercelNativeGraphOptions(TypedDict, total=False): + """Options for :func:`to_vercel_agents`.""" + + tool_handlers: dict[str, Callable[..., Any] | NativeTool] + context: LDContext + """LaunchDarkly context used for tracking events. Required for LD telemetry.""" + model: ModelSource + """A model, or a factory building one from a node's config.""" + model_factory: ModelSource + """Read when ``model`` is absent.""" + + def to_vercel_agents( - definition_promise: Any, - opts: dict[str, Any] | None = None, -) -> Any: + definition_promise: Awaitable[GraphDefinition], + opts: VercelNativeGraphOptions | None = None, +) -> NativeGraphRunner: report_usage("vercel-agents.toVercelAgents", PACKAGE_NAME, __version__) options = opts or {} @@ -273,4 +288,10 @@ async def build(node: GraphNode) -> None: finally: span.end() - return SimpleNamespace(invoke=invoke) + # A class, not a SimpleNamespace, so mypy checks invoke against NativeGraphRunner. + _invoke = invoke + + class _Runner: + invoke = staticmethod(_invoke) + + return _Runner() diff --git a/packages/vercel-messages/README.md b/packages/vercel-messages/README.md index 47610293..58e601ea 100644 --- a/packages/vercel-messages/README.md +++ b/packages/vercel-messages/README.md @@ -7,7 +7,7 @@ and model, while preserving already-qualified Gateway ids. ## Install ```bash -pip install launchdarkly-ai-server launchdarkly-server-sdk launchdarkly-ai-vercel-messages +pip install "launchdarkly-ai-server[ld]" launchdarkly-ai-vercel-messages ``` Default routing is AI Gateway: for example, `xAI` + `grok-4.5` becomes diff --git a/packages/vercel-messages/pyproject.toml b/packages/vercel-messages/pyproject.toml index da0c03fa..2244be4c 100644 --- a/packages/vercel-messages/pyproject.toml +++ b/packages/vercel-messages/pyproject.toml @@ -12,6 +12,14 @@ readme = "README.md" license = "Apache-2.0" authors = [{name = "LaunchDarkly", email = "team@launchdarkly.com"}] keywords = ["launchdarkly", "ai", "vercel", "messages"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries", +] [build-system] requires = ["hatchling>=1.32,<2"] diff --git a/packages/vercel-messages/src/launchdarkly_ai_vercel_messages/handler.py b/packages/vercel-messages/src/launchdarkly_ai_vercel_messages/handler.py index 53e310c7..e7a9f824 100644 --- a/packages/vercel-messages/src/launchdarkly_ai_vercel_messages/handler.py +++ b/packages/vercel-messages/src/launchdarkly_ai_vercel_messages/handler.py @@ -417,7 +417,7 @@ async def _run_conversation( user_input: str | None, variables: dict[str, Any], history: list[dict[str, Any]] | None, - tool_handlers: dict[str, Any] | None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None, parent: Any, *, structured: bool, @@ -541,7 +541,7 @@ def _create_vercel_messages_handler( async def invoke( cfg: AiConfigRep, user_input: str | None = None, - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: @@ -594,7 +594,7 @@ async def invoke( async def stream( cfg: AiConfigRep, user_input: str | None = None, - tool_handlers: dict[str, Any] | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: diff --git a/uv.lock b/uv.lock index d1f0a6f7..f72cfa3e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.15'", @@ -1329,7 +1329,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "anthropic", specifier = ">=0.40" }, + { name = "anthropic", specifier = ">=0.113" }, { name = "claude-agent-sdk", specifier = ">=0.2.121" }, { name = "launchdarkly-ai-server", editable = "packages/client" }, { name = "opentelemetry-api", specifier = ">=1.25" }, @@ -1347,7 +1347,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "anthropic", specifier = ">=0.40" }, + { name = "anthropic", specifier = ">=0.113" }, { name = "launchdarkly-ai-server", editable = "packages/client" }, { name = "opentelemetry-api", specifier = ">=1.25" }, ] @@ -1365,8 +1365,8 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "langchain-core", specifier = ">=0.3" }, - { name = "langgraph", specifier = ">=0.2" }, + { name = "langchain-core", specifier = ">=1.6" }, + { name = "langgraph", specifier = ">=1.2" }, { name = "launchdarkly-ai-server", editable = "packages/client" }, { name = "opentelemetry-api", specifier = ">=1.25" }, ] @@ -1383,7 +1383,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "langchain-core", specifier = ">=0.3" }, + { name = "langchain-core", specifier = ">=1.6" }, { name = "launchdarkly-ai-server", editable = "packages/client" }, { name = "opentelemetry-api", specifier = ">=1.25" }, ] @@ -1401,7 +1401,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "launchdarkly-ai-server", editable = "packages/client" }, - { name = "openai-agents", extras = ["litellm"], specifier = ">=0.2.11" }, + { name = "openai-agents", extras = ["litellm"], specifier = ">=0.17" }, { name = "opentelemetry-api", specifier = ">=1.25" }, ] @@ -1436,8 +1436,8 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "launchdarkly-ai-server", editable = "packages/client" }, - { name = "openai", specifier = ">=1.0" }, - { name = "openai-agents", specifier = ">=0.0.1" }, + { name = "openai", specifier = ">=2.44" }, + { name = "openai-agents", specifier = ">=0.17" }, { name = "opentelemetry-api", specifier = ">=1.25" }, ] @@ -1454,7 +1454,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "launchdarkly-ai-server", editable = "packages/client" }, - { name = "openai", specifier = ">=1.0" }, + { name = "openai", specifier = ">=2.44" }, { name = "opentelemetry-api", specifier = ">=1.25" }, ] @@ -1470,7 +1470,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "httpx", specifier = ">=0.27" }, + { name = "httpx", specifier = ">=0.28" }, { name = "launchdarkly-ai-server", editable = "packages/client" }, { name = "opentelemetry-api", specifier = ">=1.25" }, ] @@ -1484,6 +1484,9 @@ dependencies = [ ] [package.optional-dependencies] +ld = [ + { name = "launchdarkly-ai-server", extra = ["ld"] }, +] otel = [ { name = "launchdarkly-ai-server", extra = ["otel"] }, ] @@ -1491,9 +1494,10 @@ otel = [ [package.metadata] requires-dist = [ { name = "launchdarkly-ai-server", editable = "packages/client" }, + { name = "launchdarkly-ai-server", extras = ["ld"], marker = "extra == 'ld'", editable = "packages/client" }, { name = "launchdarkly-ai-server", extras = ["otel"], marker = "extra == 'otel'", editable = "packages/client" }, ] -provides-extras = ["otel"] +provides-extras = ["otel", "ld"] [[package]] name = "launchdarkly-ai-server" @@ -1504,6 +1508,9 @@ dependencies = [ ] [package.optional-dependencies] +ld = [ + { name = "launchdarkly-server-sdk" }, +] otel = [ { name = "opentelemetry-exporter-otlp-proto-http" }, { name = "opentelemetry-sdk" }, @@ -1511,11 +1518,12 @@ otel = [ [package.metadata] requires-dist = [ + { name = "launchdarkly-server-sdk", marker = "extra == 'ld'", specifier = ">=9.18.3,<10" }, { name = "opentelemetry-api", specifier = ">=1.25" }, { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'otel'", specifier = ">=1.25" }, { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.25" }, ] -provides-extras = ["otel"] +provides-extras = ["otel", "ld"] [[package]] name = "launchdarkly-ai-typesafe" @@ -1531,7 +1539,7 @@ dependencies = [ requires-dist = [ { name = "launchdarkly-ai-server", editable = "packages/client" }, { name = "opentelemetry-api", specifier = ">=1.25" }, - { name = "typesafe-sdk", specifier = ">=0.6" }, + { name = "typesafe-sdk", specifier = ">=0.7" }, ] [[package]] From 5cdb49029a4112f3e3f802109e4e1d096eb0f98e Mon Sep 17 00:00:00 2001 From: Jeff Dupont Date: Mon, 26 Oct 2026 13:10:37 -0700 Subject: [PATCH 2/9] fix(AIC-3598): declare langchain provider packages; add a packaging check - scripts/check_packaging.py (make check-packaging, CI job "packaging") installs each wheel into an empty virtualenv with its documented install command, imports every public module and __all__ name, and checks every module the source imports unconditionally is installed, lazy importlib.import_module calls included. Internal dependencies resolve to this commit's wheels; third-party ones from PyPI. - The check found langchain-agents and langchain-messages importing langchain_openai and langchain_anthropic without declaring them, the Python side of the JS @langchain/anthropic gap. langchain-agents now depends on langchain-openai (its default model) and offers [anthropic] and [aws] extras; langchain-messages offers [openai], [anthropic], [aws]. The Anthropic and OpenAI imports are guarded with an install hint, like Bedrock's. - README: document the 1.0 versioning rule (lockstep per language). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 18 ++ Makefile | 10 +- README.md | 4 + packages/langchain-agents/pyproject.toml | 5 + .../handler.py | 8 +- packages/langchain-messages/pyproject.toml | 5 + .../handler.py | 16 +- scripts/check_packaging.py | 222 ++++++++++++++++++ uv.lock | 28 +++ 9 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 scripts/check_packaging.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bd41528..a76f2aed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,3 +86,21 @@ jobs: echo "Building $pkg..." (cd "$pkg" && uv build --out-dir dist) done + + # ─── Packaging ────────────────────────────────────────────────────────────── + # Installs each wheel into an empty virtualenv with its documented install + # command, then imports its public modules and every module its source + # imports, lazy ones included. The workspace test run cannot catch an + # undeclared dependency, because it installs every package's at once. + packaging: + name: Packaging check + needs: [lint, typecheck, test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + python-version: "3.12" + - run: uv lock + - run: uv sync --all-packages + - run: make check-packaging diff --git a/Makefile b/Makefile index 82de8e77..1c8e9221 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: start test typecheck lint format format-check lint-fix build clean api-report api-report-check +.PHONY: start test typecheck lint format format-check lint-fix build check-packaging clean api-report api-report-check # Run main.py (uv run python main.py) start: @@ -49,6 +49,14 @@ build: (cd $$pkg && uv build --out-dir dist); \ done +# Install each built wheel into an empty virtualenv and check its imports resolve there +check-packaging: + rm -rf dist + @for pkg in packages/*/; do \ + (cd $$pkg && uv build --quiet --wheel --out-dir ../../dist); \ + done + uv run python scripts/check_packaging.py dist + # Clean build artifacts clean: rm -rf packages/*/dist diff --git a/README.md b/README.md index 0ce5db8d..58628907 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,10 @@ Tier 0 — Core Client (launchdarkly-ai-server) | [`launchdarkly-ai-vercel-agents`](packages/vercel-agents/README.md) | `*` (any) | `agent` | Native Vercel `ai.Agent` loop and graph adapter | | [`launchdarkly-ai-typesafe`](packages/typesafe/README.md) | TypeSafe | `messages` | TypeSafe Jev judge handler | +### Versioning + +From 1.0.0 every package in this SDK is released at the same version, so install them at matching versions. Each package depends on `launchdarkly-ai-server` within the same major (`>=1.0,<2`), and a breaking change in any package is a new major for all of them. Names exported from `launchdarkly_ai_server.experimental` are the exception: they may change in a minor release. + ## Quick Start ### 1. Install diff --git a/packages/langchain-agents/pyproject.toml b/packages/langchain-agents/pyproject.toml index 511179f6..a269e29f 100644 --- a/packages/langchain-agents/pyproject.toml +++ b/packages/langchain-agents/pyproject.toml @@ -7,6 +7,7 @@ dependencies = [ "opentelemetry-api>=1.25", "langchain-core>=1.6", "langgraph>=1.2", + "langchain-openai>=1.3", ] description = "LangChain/LangGraph agent handler for LaunchDarkly AI SDK" readme = "README.md" @@ -27,6 +28,10 @@ Homepage = "https://github.com/launchdarkly/python-ai-sdk" Repository = "https://github.com/launchdarkly/python-ai-sdk" "Bug Tracker" = "https://github.com/launchdarkly/python-ai-sdk/issues" +[project.optional-dependencies] +anthropic = ["langchain-anthropic>=1.4"] +aws = ["langchain-aws>=1.7"] + [build-system] requires = ["hatchling>=1.32,<2"] build-backend = "hatchling.build" diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py index be97f3fd..7ff7be0f 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py @@ -558,7 +558,13 @@ def _make_default_chat_model(config: AiConfigRep) -> Any: provider = ((config.get("provider") or {}).get("name") or "openai").lower() if provider == "anthropic": - lc_anthropic = importlib.import_module("langchain_anthropic") + try: + lc_anthropic = importlib.import_module("langchain_anthropic") + except ImportError as exc: + raise ImportError( + "Using Anthropic models requires langchain-anthropic. " + "Install it with: pip install langchain-anthropic" + ) from exc return lc_anthropic.ChatAnthropic( **_rebuild_anthropic_output_config( _rebuild_anthropic_thinking( diff --git a/packages/langchain-messages/pyproject.toml b/packages/langchain-messages/pyproject.toml index 2c5aff92..846d2d93 100644 --- a/packages/langchain-messages/pyproject.toml +++ b/packages/langchain-messages/pyproject.toml @@ -26,6 +26,11 @@ Homepage = "https://github.com/launchdarkly/python-ai-sdk" Repository = "https://github.com/launchdarkly/python-ai-sdk" "Bug Tracker" = "https://github.com/launchdarkly/python-ai-sdk/issues" +[project.optional-dependencies] +openai = ["langchain-openai>=1.3"] +anthropic = ["langchain-anthropic>=1.4"] +aws = ["langchain-aws>=1.7"] + [build-system] requires = ["hatchling>=1.32,<2"] build-backend = "hatchling.build" diff --git a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py index 9af2898d..34dabad5 100644 --- a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py +++ b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py @@ -620,7 +620,13 @@ def _make_default_chat_model(config: AiConfigRep, importlib: Any) -> Any: """ provider = config.get("provider", {}).get("name", "openai").lower() if provider == "anthropic": - lc_anthropic = importlib.import_module("langchain_anthropic") + try: + lc_anthropic = importlib.import_module("langchain_anthropic") + except ImportError as exc: + raise ImportError( + "Using Anthropic models requires langchain-anthropic. " + "Install it with: pip install langchain-anthropic" + ) from exc return lc_anthropic.ChatAnthropic( **_rebuild_anthropic_output_config( _rebuild_anthropic_thinking( @@ -651,7 +657,13 @@ def _make_default_chat_model(config: AiConfigRep, importlib: Any) -> Any: renames=_CHAT_BEDROCK_CONVERSE_RENAMES, ) ) - lc_openai = importlib.import_module("langchain_openai") + try: + lc_openai = importlib.import_module("langchain_openai") + except ImportError as exc: + raise ImportError( + "Using OpenAI models requires langchain-openai. " + "Install it with: pip install langchain-openai" + ) from exc return lc_openai.ChatOpenAI( **_model_constructor_kwargs( config, diff --git a/scripts/check_packaging.py b/scripts/check_packaging.py new file mode 100644 index 00000000..daf50586 --- /dev/null +++ b/scripts/check_packaging.py @@ -0,0 +1,222 @@ +"""Install each built wheel into an empty virtualenv and check it is usable there. + +Usage: uv run python scripts/check_packaging.py + +For each package, in a fresh virtualenv with only what its install command pulls in: + +1. every public module imports, and every name in its ``__all__`` resolves; +2. every module its source imports unconditionally is installed. That includes lazy + imports (``importlib.import_module("openai")`` inside a factory), which step 1 never + reaches. Imports under ``except ImportError`` or ``TYPE_CHECKING`` are optional. + +Catches what the workspace test run cannot, because the workspace installs every +package's dependencies at once: an import the package does not declare, a declared range +that will not install, an extra that does not deliver what it promises. + +Internal dependencies (``launchdarkly-ai-*``) resolve to the wheels in , not +PyPI: between releases a handler may require a core version only this commit builds. +Third-party dependencies resolve from PyPI against the declared ranges. +""" + +from __future__ import annotations + +import ast +import subprocess +import sys +import tempfile +import tomllib +from dataclasses import dataclass +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +# The extras the README's install command uses, and what each must make importable. +EXTRAS = {"launchdarkly-ai-server": "[ld]", "launchdarkly-ai-python": "[ld]"} +EXTRA_IMPORTS = { + "launchdarkly-ai-server": ["ldclient"], + "launchdarkly-ai-python": ["ldclient"], +} +# A uv override replaces every requirement on its name, extras included, so restate +# the extras a package's own extra requests on an internal dependency. +OVERRIDE_EXTRAS = {("launchdarkly-ai-python", "launchdarkly-ai-server"): "[ld]"} + +_IMPORT_ERRORS = {"ImportError", "ModuleNotFoundError", "Exception", "BaseException"} + +PUBLIC_CHECK = """ +import importlib, sys +for name in sys.argv[1:]: + module = importlib.import_module(name) + for attr in getattr(module, "__all__", []): + getattr(module, attr) +""" + +INSTALLED_CHECK = """ +import importlib.util, sys +missing = [name for name in sys.argv[1:] if importlib.util.find_spec(name) is None] +if missing: + sys.exit("imported but not installed: " + ", ".join(missing)) +""" + + +@dataclass(frozen=True) +class Package: + name: str + public: list[str] + """Modules whose ``__all__`` is the public contract.""" + imports: list[str] + """Top-level modules the source imports unconditionally.""" + + +def _catches_import_error(handler: ast.ExceptHandler) -> bool: + if handler.type is None: + return True + names = handler.type.elts if isinstance(handler.type, ast.Tuple) else [handler.type] + return any(isinstance(n, ast.Name) and n.id in _IMPORT_ERRORS for n in names) + + +class _Imports(ast.NodeVisitor): + def __init__(self) -> None: + self.found: set[str] = set() + self._optional = 0 + + def visit_Try(self, node: ast.Try) -> None: + guarded = any(_catches_import_error(h) for h in node.handlers) + self._optional += guarded + for stmt in node.body: + self.visit(stmt) + self._optional -= guarded + for part in (*node.handlers, *node.orelse, *node.finalbody): + self.visit(part) + + def visit_If(self, node: ast.If) -> None: + test = node.test + if (isinstance(test, ast.Name) and test.id == "TYPE_CHECKING") or ( + isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING" + ): + for stmt in node.orelse: + self.visit(stmt) + return + self.generic_visit(node) + + def _add(self, module: str) -> None: + if not self._optional: + self.found.add(module.split(".")[0]) + + def visit_Import(self, node: ast.Import) -> None: + for alias in node.names: + self._add(alias.name) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + if node.level == 0 and node.module: + self._add(node.module) + + def visit_Call(self, node: ast.Call) -> None: + func = node.func + name = ( + func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", None) + ) + if ( + name == "import_module" + and node.args + and isinstance(node.args[0], ast.Constant) + ): + if isinstance(node.args[0].value, str): + self._add(node.args[0].value) + self.generic_visit(node) + + +def source_imports(src: Path) -> list[str]: + visitor = _Imports() + for path in src.rglob("*.py"): + visitor.visit(ast.parse(path.read_text(), str(path))) + return sorted(visitor.found - set(sys.stdlib_module_names) - {"__future__"}) + + +def packages() -> list[Package]: + found = [] + for pyproject in sorted(ROOT.glob("packages/*/pyproject.toml")): + config = tomllib.loads(pyproject.read_text()) + top_level = [ + Path(p).name + for p in config["tool"]["hatch"]["build"]["targets"]["wheel"]["packages"] + ] + reported = [p.stem for p in (pyproject.parent / "api-report").glob("*.txt")] + found.append( + Package( + name=config["project"]["name"], + public=sorted({*top_level, *reported}), + imports=source_imports(pyproject.parent / "src"), + ) + ) + return found + + +def wheel_for(dist: Path, name: str) -> Path: + matches = list(dist.glob(f"{name.replace('-', '_')}-*.whl")) + if len(matches) != 1: + raise SystemExit(f"{name}: expected one wheel in {dist}, found {len(matches)}") + return matches[0] + + +def check(package: Package, dist: Path, internal: list[str], tmp: Path) -> bool: + name = package.name + venv = tmp / name + python = venv / "bin" / "python" + requirement = f"{name}{EXTRAS.get(name, '')} @ {wheel_for(dist, name).as_uri()}" + # Overriding the package being installed would drop its own extras. + overrides = tmp / f"{name}.overrides.txt" + overrides.write_text( + "".join( + f"{dep}{OVERRIDE_EXTRAS.get((name, dep), '')} @ {wheel_for(dist, dep).as_uri()}\n" + for dep in internal + if dep != name + ) + ) + steps = [ + ["uv", "venv", "--quiet", "--python", "3.12", str(venv)], + [ + "uv", + "pip", + "install", + "--quiet", + "--python", + str(python), + "--override", + str(overrides), + requirement, + ], + # -I: import from the installed wheel, never the source tree. + [str(python), "-I", "-c", PUBLIC_CHECK, *package.public], + [ + str(python), + "-I", + "-c", + INSTALLED_CHECK, + *package.imports, + *EXTRA_IMPORTS.get(name, []), + ], + ] + for step in steps: + if subprocess.run(step).returncode != 0: + print(f"FAIL {name}", file=sys.stderr) + return False + print(f"ok {name}: {', '.join(package.public)}") + return True + + +def main() -> int: + if len(sys.argv) != 2: + raise SystemExit(__doc__) + dist = Path(sys.argv[1]).resolve() + found = packages() + with tempfile.TemporaryDirectory() as tmp_dir: + internal = [p.name for p in found] + failed = [p.name for p in found if not check(p, dist, internal, Path(tmp_dir))] + if failed: + print(f"{len(failed)} package(s) failed: {', '.join(failed)}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uv.lock b/uv.lock index f72cfa3e..65ef82ed 100644 --- a/uv.lock +++ b/uv.lock @@ -1358,18 +1358,31 @@ version = "0.2.6" source = { editable = "packages/langchain-agents" } dependencies = [ { name = "langchain-core" }, + { name = "langchain-openai" }, { name = "langgraph" }, { name = "launchdarkly-ai-server" }, { name = "opentelemetry-api" }, ] +[package.optional-dependencies] +anthropic = [ + { name = "langchain-anthropic" }, +] +aws = [ + { name = "langchain-aws" }, +] + [package.metadata] requires-dist = [ + { name = "langchain-anthropic", marker = "extra == 'anthropic'", specifier = ">=1.4" }, + { name = "langchain-aws", marker = "extra == 'aws'", specifier = ">=1.7" }, { name = "langchain-core", specifier = ">=1.6" }, + { name = "langchain-openai", specifier = ">=1.3" }, { name = "langgraph", specifier = ">=1.2" }, { name = "launchdarkly-ai-server", editable = "packages/client" }, { name = "opentelemetry-api", specifier = ">=1.25" }, ] +provides-extras = ["anthropic", "aws"] [[package]] name = "launchdarkly-ai-langchain-messages" @@ -1381,12 +1394,27 @@ dependencies = [ { name = "opentelemetry-api" }, ] +[package.optional-dependencies] +anthropic = [ + { name = "langchain-anthropic" }, +] +aws = [ + { name = "langchain-aws" }, +] +openai = [ + { name = "langchain-openai" }, +] + [package.metadata] requires-dist = [ + { name = "langchain-anthropic", marker = "extra == 'anthropic'", specifier = ">=1.4" }, + { name = "langchain-aws", marker = "extra == 'aws'", specifier = ">=1.7" }, { name = "langchain-core", specifier = ">=1.6" }, + { name = "langchain-openai", marker = "extra == 'openai'", specifier = ">=1.3" }, { name = "launchdarkly-ai-server", editable = "packages/client" }, { name = "opentelemetry-api", specifier = ">=1.25" }, ] +provides-extras = ["openai", "anthropic", "aws"] [[package]] name = "launchdarkly-ai-litellm-agents" From 76c816d5e0b51fe582bac275d3e42b4507ea2850 Mon Sep 17 00:00:00 2001 From: Jeff Dupont Date: Mon, 26 Oct 2026 13:15:58 -0700 Subject: [PATCH 3/9] test(AIC-3598): cover the packaging check's import scan Co-Authored-By: Claude Opus 5.5 --- tests/test_check_packaging.py | 79 +++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/test_check_packaging.py diff --git a/tests/test_check_packaging.py b/tests/test_check_packaging.py new file mode 100644 index 00000000..c517cbe8 --- /dev/null +++ b/tests/test_check_packaging.py @@ -0,0 +1,79 @@ +"""Tests the import scan in scripts/check_packaging.py (TESTING.md §5.3). + +The install half needs built wheels and PyPI, so CI runs it as ``make check-packaging``. +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +from scripts import check_packaging + + +def _scan(tmp_path: Path, source: str) -> list[str]: + (tmp_path / "mod.py").write_text(textwrap.dedent(source)) + return check_packaging.source_imports(tmp_path) + + +def test_reads_module_level_and_lazy_imports(tmp_path: Path) -> None: + source = """ + import pkg_a + from pkg_b.sub import thing + + def factory(): + import importlib + return importlib.import_module("pkg_c.models") + """ + assert _scan(tmp_path, source) == ["pkg_a", "pkg_b", "pkg_c"] + + +def test_an_import_guarded_by_import_error_is_optional(tmp_path: Path) -> None: + source = """ + import importlib + + try: + import optional_a + except ImportError: + optional_a = None + + def load(): + try: + return importlib.import_module("optional_b") + except (ModuleNotFoundError, RuntimeError): + raise ImportError("pip install optional-b") + """ + assert _scan(tmp_path, source) == [] + + +def test_the_handlers_of_a_guard_are_not_guarded(tmp_path: Path) -> None: + source = """ + try: + import optional_a + except ImportError: + import required_fallback + """ + assert _scan(tmp_path, source) == ["required_fallback"] + + +def test_type_checking_imports_are_skipped(tmp_path: Path) -> None: + source = """ + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + import typing_only + else: + import runtime_only + """ + assert _scan(tmp_path, source) == ["runtime_only"] + + +def test_stdlib_and_relative_imports_are_skipped(tmp_path: Path) -> None: + source = """ + from __future__ import annotations + import json + from collections.abc import Callable + from . import sibling + from .sub import other + """ + assert _scan(tmp_path, source) == [] From 5df5894af3ef1659fca23df5fbe1bc29f7cf0e78 Mon Sep 17 00:00:00 2001 From: Jeff Dupont Date: Tue, 27 Oct 2026 08:07:52 -0700 Subject: [PATCH 4/9] ci(AIC-3598): re-run the API compatibility check under the breaking title Co-Authored-By: Claude Opus 5.5 From 1304692740f5064e618434d30112dd6267f72dd1 Mon Sep 17 00:00:00 2001 From: Jeff Dupont Date: Wed, 28 Oct 2026 12:55:16 -0700 Subject: [PATCH 5/9] fix(AIC-3598): address review on track(), tool_handlers typing and the packaging check - LDClientInterface.track() is typed -> None again and documented as synchronous: no call site awaits it, so an async track would drop every $ld:ai:* event. - tool_handlers takes Mapping[str, Callable[..., Any] | NativeTool] everywhere it is only read, so a caller's precisely typed dict is accepted; drops the dict() copies. - check_packaging installs every declared extra in its own venv and imports what it promises; an extra with no EXTRA_IMPORTS entry fails. Override extras are derived from pyproject. Runs go through a thread pool with buffered output. - LangChain install hints name the package extra; the READMEs list the extras. - README states that dependency floors are the CI-tested versions. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 + .../launchdarkly_ai_claude_agents.txt | 2 +- .../launchdarkly_ai_claude_agents/handler.py | 12 +- .../native_graph.py | 10 +- .../handler.py | 8 +- .../api-report/launchdarkly_ai_server.txt | 28 +-- .../src/launchdarkly_ai_server/client.py | 8 +- .../src/launchdarkly_ai_server/graph.py | 8 +- .../src/launchdarkly_ai_server/judges.py | 4 +- .../src/launchdarkly_ai_server/registry.py | 10 +- .../src/launchdarkly_ai_server/tracking.py | 32 ++-- .../src/launchdarkly_ai_server/types.py | 16 +- packages/langchain-agents/README.md | 7 + .../launchdarkly_ai_langchain_agents.txt | 2 +- .../handler.py | 12 +- .../native_graph.py | 8 +- .../langchain-agents/tests/test_handler.py | 2 +- packages/langchain-messages/README.md | 8 + .../handler.py | 12 +- .../langchain-messages/tests/test_handler.py | 5 +- .../launchdarkly_ai_litellm_agents.txt | 2 +- .../launchdarkly_ai_litellm_agents/handler.py | 10 +- .../native_graph.py | 4 +- .../handler.py | 13 +- .../launchdarkly_ai_openai_agents.txt | 2 +- .../launchdarkly_ai_openai_agents/handler.py | 10 +- .../native_graph.py | 8 +- .../handler.py | 6 +- .../handler.py | 8 +- .../src/launchdarkly_ai_typesafe/handler.py | 2 +- .../launchdarkly_ai_vercel_agents.txt | 2 +- .../launchdarkly_ai_vercel_agents/handler.py | 6 +- .../native_graph.py | 4 +- .../handler.py | 10 +- scripts/check_packaging.py | 161 ++++++++++++++---- tests/test_check_packaging.py | 34 ++++ uv.lock | 28 +-- 37 files changed, 328 insertions(+), 178 deletions(-) diff --git a/README.md b/README.md index ee3f859a..e7181572 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,8 @@ Tier 0 — Core Client (launchdarkly-ai-server) From 1.0.0 every package in this SDK is released at the same version, so install them at matching versions. Each package depends on `launchdarkly-ai-server` within the same major (`>=1.0,<2`), and a breaking change in any package is a new major for all of them. Names exported from `launchdarkly_ai_server.experimental` are the exception: they may change in a minor release. +Third-party dependency floors are the versions CI tests against, not the oldest release that might work. A floor moves down only when the test suite runs at the lower version. + ## Quick Start ### 1. Install diff --git a/packages/claude-agents/api-report/launchdarkly_ai_claude_agents.txt b/packages/claude-agents/api-report/launchdarkly_ai_claude_agents.txt index 0f9b269b..5269315f 100644 --- a/packages/claude-agents/api-report/launchdarkly_ai_claude_agents.txt +++ b/packages/claude-agents/api-report/launchdarkly_ai_claude_agents.txt @@ -9,7 +9,7 @@ ClaudeNativeGraphOptions(TypedDict, total=False) [class] context: LDContext cwd: str | os.PathLike[str] setting_sources: list[SettingSource] - tool_handlers: dict[str, Callable[..., Any] | NativeTool] + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] ClaudeNotebookEdit = NativeTool('NotebookEdit') ClaudeRead = NativeTool('Read') ClaudeTodoWrite = NativeTool('TodoWrite') diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py index 0a3529a4..d5f027f1 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py @@ -176,7 +176,7 @@ async def _build_tool_mcp( config_tools: dict[str, Any], - handlers: dict[str, Any], + handlers: Mapping[str, Any], ) -> Any: """Build an in-process SDK MCP server from LD config tools + handler functions. @@ -213,7 +213,7 @@ async def _execute(args: Any, _name: str = tool_name) -> dict[str, Any]: def _partition_tools( config_tools: dict[str, Any] | None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool], + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool], ) -> tuple[dict[str, Any], dict[str, Any], list[str]]: """ Returns (native_tool_map, user_config_tools, native_tool_names). @@ -240,7 +240,7 @@ def _partition_tools( def _native_tool_aliases( - tool_handlers: dict[str, Callable[..., Any] | NativeTool], + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool], ) -> dict[str, str]: """AI Config key → provider tool name, for the config's native tools only. @@ -780,7 +780,7 @@ def _create_claude_agents_handler( async def _call_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: @@ -966,7 +966,7 @@ async def _call_impl( def _stream_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: @@ -1037,7 +1037,7 @@ def _map_claude_stream_event( async def _stream_gen( config: AiConfigRep, user_input: str, - tool_handlers: dict[str, Callable[..., Any] | NativeTool], + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool], variables: dict[str, Any], history: list[dict[str, Any]] | None = None, *, diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py index e843c638..bddb33a7 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py @@ -10,7 +10,7 @@ import re import time import uuid -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping from typing import Any, TypedDict from claude_agent_sdk import SettingSource @@ -64,7 +64,7 @@ def _sanitize_name(key: str) -> str: def _wrap_native_tools( - tool_handlers: dict[str, Callable[..., Any] | NativeTool], + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool], ld_context: Any, track_data: dict[str, Any], ) -> dict[str, Any]: @@ -115,7 +115,7 @@ async def _run_query( node: GraphNode, input_text: str, variables: dict[str, Any], - tool_handlers: dict[str, Callable[..., Any] | NativeTool], + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool], ld_context: Any, graph_key: str, run_id: str, @@ -227,7 +227,7 @@ async def _run_query( class ClaudeNativeGraphOptions(TypedDict, total=False): """Options for :func:`to_claude_agents`.""" - tool_handlers: dict[str, Callable[..., Any] | NativeTool] + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] context: LDContext """LaunchDarkly context used for tracking events. Required for LD telemetry.""" cwd: str | os.PathLike[str] @@ -278,7 +278,7 @@ async def invoke( if raw_ld_context is not None else None ) - raw_handlers: dict[str, Any] = _opts.get("tool_handlers") or {} + raw_handlers: Mapping[str, Any] = _opts.get("tool_handlers") or {} start_time = time.monotonic() run_id = str(uuid.uuid4()) diff --git a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py index 8fab2876..9ee211aa 100644 --- a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py +++ b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py @@ -362,7 +362,7 @@ async def _run_tool_loop( config: AiConfigRep, messages: list[dict[str, Any]], system: str | None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool], + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool], *, capture_content: bool = False, parent: Any = None, @@ -562,7 +562,7 @@ def _create_claude_messages_handler( async def _call_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: @@ -640,7 +640,7 @@ async def _call_impl( def _stream_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: @@ -697,7 +697,7 @@ async def _stream_gen( client: Any, config: AiConfigRep, user_input: str, - tool_handlers: dict[str, Callable[..., Any] | NativeTool], + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool], variables: dict[str, Any], history: list[dict[str, Any]] | None = None, *, diff --git a/packages/client/api-report/launchdarkly_ai_server.txt b/packages/client/api-report/launchdarkly_ai_server.txt index fe0b609a..1a790419 100644 --- a/packages/client/api-report/launchdarkly_ai_server.txt +++ b/packages/client/api-report/launchdarkly_ai_server.txt @@ -6,10 +6,10 @@ any_multimodal(turns: list[dict[str, Any]]) -> bool build_judge_tasks(*, config: AiConfigRep, user_context: LDContext, handler: ProviderHandler, handlers: list[ProviderHandler] | None = None, llm_response: str, base_track_data: TrackData, user_input: str | None = None, trajectory: str = '') -> list[JudgeTask] [async] compose(a: Registry, b: Registry) -> Registry compose_history(*, history: list[dict[str, Any]], user_input: str | None = None, config_messages: list[dict[str, Any]] | None = None) -> list[dict[str, Any]] -config(*, key: str, handler: ProviderHandler | list[ProviderHandler] | None = None, tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, registry: Any = None, skip_judges: bool = False) -> ConfigInstance +config(*, key: str, handler: ProviderHandler | list[ProviderHandler] | None = None, tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, registry: Any = None, skip_judges: bool = False) -> ConfigInstance config_skills(key: str, context: LDContext) -> SkillSource ConfigInstance [class] - __init__(key: str, handler: ProviderHandler | list[ProviderHandler] | None, tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None, registry: Any, skip_judges: bool = False) + __init__(key: str, handler: ProviderHandler | list[ProviderHandler] | None, tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None, registry: Any, skip_judges: bool = False) invoke(user_input: str | None, context: LDContext, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None) -> ProviderResponse[Any] [async] stream(user_input: str | None, context: LDContext, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None) -> AsyncGenerator[StreamEvent, None] configure(*, options: SDKOptions) -> None @@ -24,8 +24,8 @@ create_handler(provides_for: tuple[str, Literal['agent', 'messages']], fn: _Hand create_run_usage() -> RunUsage end_span_once(span: Any, tracker: set[int], abandoned: bool = False, cancelled: bool = False) -> None end_unfinished_spans(*spans: Any) -> None -execute_and_stream(*, config_key: str, config: AiConfigRep, meta: VariationMeta, user_context: LDContext, handler: ProviderHandler, user_input: str | None = None, tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, graph_key: str | None = None, history: list[dict[str, Any]] | None = None) -> AsyncGenerator[ExecuteStreamEvent, None] [async] -execute_and_track(*, config_key: str, config: AiConfigRep, meta: VariationMeta, user_context: LDContext, handler: ProviderHandler, user_input: str | None = None, tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, graph_key: str | None = None, history: list[dict[str, Any]] | None = None) -> dict[str, Any] [async] +execute_and_stream(*, config_key: str, config: AiConfigRep, meta: VariationMeta, user_context: LDContext, handler: ProviderHandler, user_input: str | None = None, tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, graph_key: str | None = None, history: list[dict[str, Any]] | None = None) -> AsyncGenerator[ExecuteStreamEvent, None] [async] +execute_and_track(*, config_key: str, config: AiConfigRep, meta: VariationMeta, user_context: LDContext, handler: ProviderHandler, user_input: str | None = None, tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, graph_key: str | None = None, history: list[dict[str, Any]] | None = None) -> dict[str, Any] [async] ExecuteStreamDoneEvent = dict[str, Any] ExecuteStreamEvent = dict[str, Any] extract_variation(config_key: str, context: dict[str, Any]) -> dict[str, Any] [async] @@ -35,7 +35,7 @@ get_skill(key: str, *, version: int | None = None) -> Skill | None [async] get_skill_result(key: str, *, version: int | None = None) -> SkillOutcome [async] get_skills(refs: Sequence[SkillReference | str]) -> list[Skill] [async] global_registry = Registry() -graph(key: str, *, handlers: list[ProviderHandler] | None = None, tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, registry: Any = None, graph_judge: str | None = None) -> GraphInstance +graph(key: str, *, handlers: list[ProviderHandler] | None = None, tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, registry: Any = None, graph_judge: str | None = None) -> GraphInstance GraphArgs = dict[str, Any] GraphDefinition [class] __init__(*, key: str, enabled: bool, root: GraphNode | None, get_node: Callable[[str], GraphNode | None], get_child_nodes: Callable[[str], list[GraphNode]], get_parent_nodes: Callable[[str], list[GraphNode]], terminal_nodes: Callable[[], list[GraphNode]], is_terminal: Callable[[str], bool], edges_from: Callable[[str], list[GraphEdge]], run_node: Callable[..., Any], route: Callable[..., Any], traverse: Callable[..., Any], reverse_traverse: Callable[..., Any]) @@ -143,7 +143,7 @@ LaunchDarklySkillStore [class] LDClientInterface(Protocol) [class] close() -> Any flush() -> Any - track(event_name: str, context: Any, data: Any = None, metric_value: float | None = None, /) -> Any + track(event_name: str, context: Any, data: Any = None, metric_value: float | None = None, /) -> None variation(key: str, context: Any, default_value: Any, /) -> Any LDContext = dict[str, Any] make_track_data(node: GraphNode, graph_key: str, run_id: str) -> dict[str, Any] @@ -184,11 +184,11 @@ ProviderGraphResponse [class, @dataclass] usage: UsageDict ProviderHandler [class] __init__(fn: _HandlerFn, provides_for: tuple[str, Literal['agent', 'messages']] | None = None, stream_fn: _StreamFn | None = None, capture_content: bool | None = None) - __call__(config: AiConfigRep, user_input: str | None = None, tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None) -> HandlerResult [async] + __call__(config: AiConfigRep, user_input: str | None = None, tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None) -> HandlerResult [async] capture_content: bool [property] has_stream: bool [property] provides_for: tuple[str, Literal['agent', 'messages']] | None = provides_for - stream(config: AiConfigRep, user_input: str | None = None, tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None) -> AsyncGenerator[dict[str, Any], None] [async] + stream(config: AiConfigRep, user_input: str | None = None, tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None) -> AsyncGenerator[dict[str, Any], None] [async] ProviderResponse(Generic[T]) [class, @dataclass] __init__(response: T, usage: UsageDict, judge_results: dict[str, JudgeResult] | None = None, judge_tasks: list[JudgeTask] | None = None, track_data: TrackData | None = None) judge_results: dict[str, JudgeResult] | None = None @@ -211,16 +211,16 @@ ReconcileReport [class, @dataclass(frozen=True)] ok: bool [property] register_ai_sdk_package(name: str, version: str) -> None Registry [class] - __init__(*, handlers: list[ProviderHandler] | None = None, tools: dict[str, Callable[..., Any] | NativeTool] | None = None) + __init__(*, handlers: list[ProviderHandler] | None = None, tools: Mapping[str, Callable[..., Any] | NativeTool] | None = None) handlers: list[ProviderHandler] [property] - register(*, handlers: list[ProviderHandler] | None = None, tools: dict[str, Callable[..., Any] | NativeTool] | None = None) -> None + register(*, handlers: list[ProviderHandler] | None = None, tools: Mapping[str, Callable[..., Any] | NativeTool] | None = None) -> None tools: dict[str, Callable[..., Any] | NativeTool] [property] report_usage(helper: str, package_name: str | None = None, package_version: str | None = None) -> None -resolve_graph(key: str, *, context: LDContext, handlers: list[ProviderHandler] | None = None, tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, registry: Any = None) -> GraphDefinition [async] +resolve_graph(key: str, *, context: LDContext, handlers: list[ProviderHandler] | None = None, tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, registry: Any = None) -> GraphDefinition [async] resolve_handlers(registry: Registry | None, local_handlers: list[ProviderHandler] | None) -> list[ProviderHandler] | None -resolve_tools(registry: Registry | None, local_tools: dict[str, Callable[..., Any] | NativeTool] | None) -> dict[str, Callable[..., Any] | NativeTool] | None +resolve_tools(registry: Registry | None, local_tools: Mapping[str, Callable[..., Any] | NativeTool] | None) -> Mapping[str, Callable[..., Any] | NativeTool] | None run_judge(task: JudgeTask, handlers: list[ProviderHandler]) -> JudgeRunResult | None [async] -run_judges(*, config: AiConfigRep, user_context: LDContext, handler: ProviderHandler, handlers: list[ProviderHandler] | None = None, user_input: str | None, llm_response: str, base_track_data: TrackData, tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, graph_key: str | None = None, trajectory: str = '') -> dict[str, JudgeResult] [async] +run_judges(*, config: AiConfigRep, user_context: LDContext, handler: ProviderHandler, handlers: list[ProviderHandler] | None = None, user_input: str | None, llm_response: str, base_track_data: TrackData, tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, graph_key: str | None = None, trajectory: str = '') -> dict[str, JudgeResult] [async] RunUsage [class, @dataclass] __init__(total: SpanUsage = SpanUsage(), _turns: int = 0) add(turn: SpanUsage | None) -> None @@ -331,5 +331,5 @@ UsageDict [class, @dataclass] total: int = 0 VariationMeta = dict[str, Any] watch_skills(skills: Sequence[Skill | SkillReference | str] | str | SkillSource, root: str | os.PathLike[str], *, prune: bool = True, timeout: float = 10.0, on_unavailable: OnUnavailable = 'keep', debounce: float = DEFAULT_DEBOUNCE_SECONDS, on_reconcile: Callable[[ReconcileReport], Any] | None = None) -> tuple[ReconcileReport, SkillWatcher] [async] -wrap_tool_handlers(tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None, user_context: LDContext, track_data: TrackData) -> dict[str, Callable[..., Any]] +wrap_tool_handlers(tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None, user_context: LDContext, track_data: TrackData) -> dict[str, Callable[..., Any]] write_skills(skills: Sequence[Skill | SkillReference | str] | str | SkillSource, root: str | os.PathLike[str], *, prune: bool = True, timeout: float = 10.0, on_unavailable: OnUnavailable = 'keep') -> ReconcileReport [async] diff --git a/packages/client/src/launchdarkly_ai_server/client.py b/packages/client/src/launchdarkly_ai_server/client.py index be124d87..48d1109c 100644 --- a/packages/client/src/launchdarkly_ai_server/client.py +++ b/packages/client/src/launchdarkly_ai_server/client.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import AsyncGenerator, Callable +from collections.abc import AsyncGenerator, Callable, Mapping from contextlib import aclosing from typing import Any @@ -52,7 +52,7 @@ def __init__( self, key: str, handler: ProviderHandler | list[ProviderHandler] | None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None, registry: Any, # Registry | None skip_judges: bool = False, ) -> None: @@ -304,7 +304,7 @@ def config( *, key: str, handler: ProviderHandler | list[ProviderHandler] | None = None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, registry: Any = None, skip_judges: bool = False, ) -> ConfigInstance: @@ -333,7 +333,7 @@ def _config( *, key: str, handler: ProviderHandler | list[ProviderHandler] | None = None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, registry: Any = None, skip_judges: bool = False, ) -> ConfigInstance: diff --git a/packages/client/src/launchdarkly_ai_server/graph.py b/packages/client/src/launchdarkly_ai_server/graph.py index 4f8677b6..3593d8d8 100644 --- a/packages/client/src/launchdarkly_ai_server/graph.py +++ b/packages/client/src/launchdarkly_ai_server/graph.py @@ -6,7 +6,7 @@ import re as _re import time import uuid -from collections.abc import AsyncGenerator, Callable +from collections.abc import AsyncGenerator, Callable, Mapping from typing import Any from .agui import StableProjector @@ -837,7 +837,7 @@ async def resolve_graph( *, context: LDContext, handlers: list[ProviderHandler] | None = None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, registry: Any = None, ) -> GraphDefinition: """ @@ -1345,7 +1345,7 @@ def graph( key: str, *, handlers: list[ProviderHandler] | None = None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, registry: Any = None, graph_judge: str | None = None, ) -> GraphInstance: @@ -1367,7 +1367,7 @@ def _graph( key: str, *, handlers: list[ProviderHandler] | None = None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, registry: Any = None, graph_judge: str | None = None, ) -> GraphInstance: diff --git a/packages/client/src/launchdarkly_ai_server/judges.py b/packages/client/src/launchdarkly_ai_server/judges.py index d46b9c0a..93c9cec3 100644 --- a/packages/client/src/launchdarkly_ai_server/judges.py +++ b/packages/client/src/launchdarkly_ai_server/judges.py @@ -2,7 +2,7 @@ import logging import random -from collections.abc import Callable +from collections.abc import Callable, Mapping from typing import Any from .conversation import with_judge_evaluation @@ -92,7 +92,7 @@ async def run_judges( user_input: str | None, llm_response: str, base_track_data: TrackData, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, graph_key: str | None = None, trajectory: str = "", ) -> dict[str, JudgeResult]: diff --git a/packages/client/src/launchdarkly_ai_server/registry.py b/packages/client/src/launchdarkly_ai_server/registry.py index 3fcede91..6d4f63c6 100644 --- a/packages/client/src/launchdarkly_ai_server/registry.py +++ b/packages/client/src/launchdarkly_ai_server/registry.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from collections.abc import Callable +from collections.abc import Callable, Mapping from typing import Any from .types import NativeTool, ProviderHandler @@ -19,7 +19,7 @@ def __init__( self, *, handlers: list[ProviderHandler] | None = None, - tools: dict[str, Callable[..., Any] | NativeTool] | None = None, + tools: Mapping[str, Callable[..., Any] | NativeTool] | None = None, ) -> None: self._handlers: list[ProviderHandler] = list(handlers) if handlers else [] self._tools: dict[str, Callable[..., Any] | NativeTool] = ( @@ -38,7 +38,7 @@ def register( self, *, handlers: list[ProviderHandler] | None = None, - tools: dict[str, Callable[..., Any] | NativeTool] | None = None, + tools: Mapping[str, Callable[..., Any] | NativeTool] | None = None, ) -> None: if handlers: for handler in handlers: @@ -112,8 +112,8 @@ def resolve_handlers( def resolve_tools( registry: Registry | None, - local_tools: dict[str, Callable[..., Any] | NativeTool] | None, -) -> dict[str, Callable[..., Any] | NativeTool] | None: + local_tools: Mapping[str, Callable[..., Any] | NativeTool] | None, +) -> Mapping[str, Callable[..., Any] | NativeTool] | None: """ Merges registry tools with locally-supplied tools. Local keys win. """ diff --git a/packages/client/src/launchdarkly_ai_server/tracking.py b/packages/client/src/launchdarkly_ai_server/tracking.py index 418e8156..e93f6918 100644 --- a/packages/client/src/launchdarkly_ai_server/tracking.py +++ b/packages/client/src/launchdarkly_ai_server/tracking.py @@ -4,7 +4,7 @@ import os import time import uuid -from collections.abc import AsyncGenerator, AsyncIterator, Callable +from collections.abc import AsyncGenerator, AsyncIterator, Callable, Mapping from contextlib import asynccontextmanager from typing import Any @@ -53,7 +53,7 @@ def _try_get_environment_id() -> str | None: def wrap_tool_handlers( - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None, user_context: LDContext, track_data: TrackData, ) -> dict[str, Callable[..., Any]]: @@ -128,7 +128,7 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: def _exposed_tool_keys( config: AiConfigRep, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None, ) -> list[str]: """The tool keys this config offered the model, in declaration order. @@ -163,7 +163,7 @@ async def execute_and_track( user_context: LDContext, handler: ProviderHandler, user_input: str | None = None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, graph_key: str | None = None, history: list[dict[str, Any]] | None = None, @@ -202,13 +202,10 @@ async def execute_and_track( # with an empty result. One recorder per invocation; they run concurrently. exposed_tool_keys = _exposed_tool_keys(config, tool_handlers) recorder = TrajectoryRecorder() - # Copied into the handler contract's type: dict is invariant in its values. - tracked_tool_handlers: dict[str, Callable[..., Any] | NativeTool] = dict( - wrap_tool_handlers( - recorder.wrap(tool_handlers or {}, exposed=exposed_tool_keys), - ld_ctx, - track_data, - ) + tracked_tool_handlers = wrap_tool_handlers( + recorder.wrap(tool_handlers or {}, exposed=exposed_tool_keys), + ld_ctx, + track_data, ) merged_variables: dict[str, Any] = { **(variables or {}), @@ -265,7 +262,7 @@ async def execute_and_stream( user_context: LDContext, handler: ProviderHandler, user_input: str | None = None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, graph_key: str | None = None, history: list[dict[str, Any]] | None = None, @@ -305,13 +302,10 @@ async def execute_and_stream( exposed_tool_keys = _exposed_tool_keys(config, tool_handlers) recorder = TrajectoryRecorder() - # Copied into the handler contract's type: dict is invariant in its values. - tracked_tool_handlers: dict[str, Callable[..., Any] | NativeTool] = dict( - wrap_tool_handlers( - recorder.wrap(tool_handlers or {}, exposed=exposed_tool_keys), - ld_ctx, - track_data, - ) + tracked_tool_handlers = wrap_tool_handlers( + recorder.wrap(tool_handlers or {}, exposed=exposed_tool_keys), + ld_ctx, + track_data, ) merged_variables: dict[str, Any] = { **(variables or {}), diff --git a/packages/client/src/launchdarkly_ai_server/types.py b/packages/client/src/launchdarkly_ai_server/types.py index f22d260b..aa7c5e57 100644 --- a/packages/client/src/launchdarkly_ai_server/types.py +++ b/packages/client/src/launchdarkly_ai_server/types.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import AsyncGenerator, Awaitable, Callable, Sequence +from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass, field from typing import Any, Generic, Literal, Protocol, TypeVar @@ -23,7 +23,9 @@ class LDClientInterface(Protocol): """ Structural type for any LaunchDarkly client; ``ldclient.LDClient`` satisfies it. - Methods may be sync or async: the SDK awaits any awaitable result. ``context`` is + ``variation``, ``flush`` and ``close`` may be sync or async: the SDK awaits any + awaitable result. ``track`` must be synchronous: the SDK calls it from sync code and + drops the result, so an ``async def track`` would never send an event. ``context`` is ``Any`` because ``ldclient`` takes an ``ldclient.Context``, which the SDK builds from an ``LDContext`` before the call. Parameters are positional-only so implementations may name them freely (``ldclient`` calls the third ``variation`` argument ``default``). @@ -38,7 +40,7 @@ def track( data: Any = None, metric_value: float | None = None, /, - ) -> Any: ... + ) -> None: ... def flush(self) -> Any: ... @@ -136,7 +138,7 @@ class HandlerStreamEvent: [ AiConfigRep, str | None, - dict[str, Callable[..., Any] | NativeTool] | None, + Mapping[str, Callable[..., Any] | NativeTool] | None, dict[str, Any] | None, list[dict[str, Any]] | None, ], @@ -146,7 +148,7 @@ class HandlerStreamEvent: [ AiConfigRep, str | None, - dict[str, Callable[..., Any] | NativeTool] | None, + Mapping[str, Callable[..., Any] | NativeTool] | None, dict[str, Any] | None, list[dict[str, Any]] | None, ], @@ -204,7 +206,7 @@ async def __call__( self, config: AiConfigRep, user_input: str | None = None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> HandlerResult: @@ -217,7 +219,7 @@ async def stream( self, config: AiConfigRep, user_input: str | None = None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: diff --git a/packages/langchain-agents/README.md b/packages/langchain-agents/README.md index b6eaf9c5..01fa9358 100644 --- a/packages/langchain-agents/README.md +++ b/packages/langchain-agents/README.md @@ -12,6 +12,13 @@ pip install launchdarkly-ai-server launchdarkly-ai-langchain-agents The default model is `ChatOpenAI`, so set `OPENAI_API_KEY` unless you pass a custom `BaseChatModel`. +For AI configs whose provider is Anthropic or Bedrock, install the matching extra: + +```bash +pip install "launchdarkly-ai-langchain-agents[anthropic]" # langchain-anthropic +pip install "launchdarkly-ai-langchain-agents[aws]" # langchain-aws (Bedrock) +``` + ## Usage ### With the default model (`ChatOpenAI`) diff --git a/packages/langchain-agents/api-report/launchdarkly_ai_langchain_agents.txt b/packages/langchain-agents/api-report/launchdarkly_ai_langchain_agents.txt index d01dc20e..6756600a 100644 --- a/packages/langchain-agents/api-report/launchdarkly_ai_langchain_agents.txt +++ b/packages/langchain-agents/api-report/launchdarkly_ai_langchain_agents.txt @@ -5,5 +5,5 @@ langchain_graph(key: str, llm: Any = None, **options: Any) -> Any LangChainNativeGraphOptions(TypedDict, total=False) [class] context: LDContext model_factory: Callable[[GraphNode], Any] - tool_handlers: dict[str, Callable[..., Any] | NativeTool] + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] to_lang_graph(def_promise: Awaitable[GraphDefinition], opts: LangChainNativeGraphOptions | None = None) -> NativeGraphRunner diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py index 7ff7be0f..7ed6cbc8 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py @@ -391,7 +391,7 @@ def _rebuild_anthropic_output_config(kwargs: dict[str, Any]) -> dict[str, Any]: def _build_agent_tools( config_tools: dict[str, Any], - tool_handlers: dict[str, Callable[..., Any] | NativeTool], + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool], ) -> list[Any]: import importlib @@ -563,7 +563,7 @@ def _make_default_chat_model(config: AiConfigRep) -> Any: except ImportError as exc: raise ImportError( "Using Anthropic models requires langchain-anthropic. " - "Install it with: pip install langchain-anthropic" + 'Install it with: pip install "launchdarkly-ai-langchain-agents[anthropic]"' ) from exc return lc_anthropic.ChatAnthropic( **_rebuild_anthropic_output_config( @@ -584,7 +584,7 @@ def _make_default_chat_model(config: AiConfigRep) -> Any: except ImportError as exc: raise ImportError( "Using Bedrock models requires langchain-aws. " - "Install it with: pip install langchain-aws" + 'Install it with: pip install "launchdarkly-ai-langchain-agents[aws]"' ) from exc return lc_aws.ChatBedrockConverse( **_model_constructor_kwargs( @@ -668,7 +668,7 @@ def _create_langchain_agents_handler( async def _call_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: @@ -799,7 +799,7 @@ async def _call_impl( def _stream_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: @@ -825,7 +825,7 @@ async def _stream_gen( llm: Any, config: AiConfigRep, user_input: str, - tool_handlers: dict[str, Callable[..., Any] | NativeTool], + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool], variables: dict[str, Any], history: list[dict[str, Any]] | None = None, *, diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py index 56f131b2..8bc1a375 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py @@ -9,7 +9,7 @@ import re import time import uuid -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping from typing import Annotated, Any, TypedDict from launchdarkly_ai_server import ( @@ -71,7 +71,7 @@ def _build_system_prompt(node: GraphNode, variables: dict[str, Any]) -> str | No def _build_node_tools( node: GraphNode, - tool_handlers: dict[str, Callable[..., Any] | NativeTool], + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool], ) -> list[Any]: import importlib @@ -126,7 +126,7 @@ def _extract_usage(msg: Any) -> dict[str, int]: class LangChainNativeGraphOptions(TypedDict, total=False): """Options for :func:`to_lang_graph`.""" - tool_handlers: dict[str, Callable[..., Any] | NativeTool] + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] model_factory: Callable[[GraphNode], Any] """Builds the chat model for a node. Defaults to ``ChatOpenAI``.""" context: LDContext @@ -183,7 +183,7 @@ async def invoke( if not root: raise ValueError(f'Graph "{def_obj.key}" has no root node') - tool_handlers: dict[str, Callable[..., Any] | NativeTool] = ( + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] = ( _opts.get("tool_handlers") or {} ) model_factory = _opts.get("model_factory") diff --git a/packages/langchain-agents/tests/test_handler.py b/packages/langchain-agents/tests/test_handler.py index 0095fb1b..731aff1b 100644 --- a/packages/langchain-agents/tests/test_handler.py +++ b/packages/langchain-agents/tests/test_handler.py @@ -2703,7 +2703,7 @@ def test_bedrock_without_langchain_aws_has_a_clear_error(self) -> None: ): with pytest.raises( ImportError, - match=r"pip install langchain-aws", + match=r"pip install \"launchdarkly-ai-langchain-agents\[aws\]\"", ): handler_mod._make_default_chat_model(cfg) diff --git a/packages/langchain-messages/README.md b/packages/langchain-messages/README.md index abe5e2b1..2ed7532e 100644 --- a/packages/langchain-messages/README.md +++ b/packages/langchain-messages/README.md @@ -12,6 +12,14 @@ pip install launchdarkly-ai-server launchdarkly-ai-langchain-messages The default model is `ChatOpenAI`, so set `OPENAI_API_KEY` unless you pass a custom `BaseChatModel`. +Install the extra for each provider your AI configs use: + +```bash +pip install "launchdarkly-ai-langchain-messages[openai]" # langchain-openai (the default model) +pip install "launchdarkly-ai-langchain-messages[anthropic]" # langchain-anthropic +pip install "launchdarkly-ai-langchain-messages[aws]" # langchain-aws (Bedrock) +``` + ## Usage ### With the default model (`ChatOpenAI`) diff --git a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py index 34dabad5..3834e6ea 100644 --- a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py +++ b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py @@ -625,7 +625,7 @@ def _make_default_chat_model(config: AiConfigRep, importlib: Any) -> Any: except ImportError as exc: raise ImportError( "Using Anthropic models requires langchain-anthropic. " - "Install it with: pip install langchain-anthropic" + 'Install it with: pip install "launchdarkly-ai-langchain-messages[anthropic]"' ) from exc return lc_anthropic.ChatAnthropic( **_rebuild_anthropic_output_config( @@ -646,7 +646,7 @@ def _make_default_chat_model(config: AiConfigRep, importlib: Any) -> Any: except ImportError as exc: raise ImportError( "Using Bedrock models requires langchain-aws. " - "Install it with: pip install langchain-aws" + 'Install it with: pip install "launchdarkly-ai-langchain-messages[aws]"' ) from exc return lc_aws.ChatBedrockConverse( **_model_constructor_kwargs( @@ -662,7 +662,7 @@ def _make_default_chat_model(config: AiConfigRep, importlib: Any) -> Any: except ImportError as exc: raise ImportError( "Using OpenAI models requires langchain-openai. " - "Install it with: pip install langchain-openai" + 'Install it with: pip install "launchdarkly-ai-langchain-messages[openai]"' ) from exc return lc_openai.ChatOpenAI( **_model_constructor_kwargs( @@ -804,7 +804,7 @@ def _create_langchain_messages_handler( async def _call_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: @@ -1098,7 +1098,7 @@ async def _call_impl( def _stream_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: @@ -1124,7 +1124,7 @@ async def _stream_gen( llm: Any, config: AiConfigRep, user_input: str, - tool_handlers: dict[str, Callable[..., Any] | NativeTool], + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool], variables: dict[str, Any], history: list[dict[str, Any]] | None = None, *, diff --git a/packages/langchain-messages/tests/test_handler.py b/packages/langchain-messages/tests/test_handler.py index d18aed5f..de98fbd3 100644 --- a/packages/langchain-messages/tests/test_handler.py +++ b/packages/langchain-messages/tests/test_handler.py @@ -2828,7 +2828,10 @@ def test_bedrock_without_langchain_aws_has_a_clear_error(self) -> None: imports.import_module.side_effect = ModuleNotFoundError( "No module named 'langchain_aws'" ) - with pytest.raises(ImportError, match=r"pip install langchain-aws"): + with pytest.raises( + ImportError, + match=r"pip install \"launchdarkly-ai-langchain-messages\[aws\]\"", + ): handler_mod._make_default_chat_model(cfg, imports) @pytest.mark.asyncio diff --git a/packages/litellm-agents/api-report/launchdarkly_ai_litellm_agents.txt b/packages/litellm-agents/api-report/launchdarkly_ai_litellm_agents.txt index a8d2a04a..f55868e6 100644 --- a/packages/litellm-agents/api-report/launchdarkly_ai_litellm_agents.txt +++ b/packages/litellm-agents/api-report/launchdarkly_ai_litellm_agents.txt @@ -7,5 +7,5 @@ LiteLLMNativeGraphOptions(TypedDict, total=False) [class] base_url: str context: LDContext model_factory: ModelFactory - tool_handlers: dict[str, Callable[..., Any] | NativeTool] + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] to_litellm_agents(definition: Awaitable[GraphDefinition], opts: LiteLLMNativeGraphOptions | None = None) -> NativeGraphRunner diff --git a/packages/litellm-agents/src/launchdarkly_ai_litellm_agents/handler.py b/packages/litellm-agents/src/launchdarkly_ai_litellm_agents/handler.py index c14c86a9..6531fa3f 100644 --- a/packages/litellm-agents/src/launchdarkly_ai_litellm_agents/handler.py +++ b/packages/litellm-agents/src/launchdarkly_ai_litellm_agents/handler.py @@ -266,7 +266,7 @@ async def _call_tool(handler: Any, arguments: dict[str, Any]) -> Any: return await result if inspect.isawaitable(result) else result -def _tools(config_value: AiConfigRep, handlers: dict[str, Any]) -> list[Any]: +def _tools(config_value: AiConfigRep, handlers: Mapping[str, Any]) -> list[Any]: import importlib FunctionTool = importlib.import_module("agents").FunctionTool @@ -503,7 +503,7 @@ def close_open(self, *, abandoned: bool = False, cancelled: bool = False) -> Non def _agent_and_prompt( config_value: AiConfigRep, user_input: str | None, - handlers: dict[str, Any], + handlers: Mapping[str, Any], variables: dict[str, Any], history: list[dict[str, Any]] | None, model_factory: ModelFactory, @@ -573,7 +573,7 @@ def model_factory(name: str, _parameters: dict[str, Any]) -> Any: async def call( config_value: AiConfigRep, user_input: str | None = None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: @@ -639,7 +639,7 @@ async def call( def stream( config_value: AiConfigRep, user_input: str | None = None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: @@ -664,7 +664,7 @@ def stream( async def _stream( config_value: AiConfigRep, user_input: str | None, - handlers: dict[str, Any], + handlers: Mapping[str, Any], variables: dict[str, Any], history: list[dict[str, Any]] | None, model_factory: ModelFactory, diff --git a/packages/litellm-agents/src/launchdarkly_ai_litellm_agents/native_graph.py b/packages/litellm-agents/src/launchdarkly_ai_litellm_agents/native_graph.py index 55cd79e5..77eb12f5 100644 --- a/packages/litellm-agents/src/launchdarkly_ai_litellm_agents/native_graph.py +++ b/packages/litellm-agents/src/launchdarkly_ai_litellm_agents/native_graph.py @@ -8,7 +8,7 @@ import time import types import uuid -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping from typing import Any, TypedDict from agents import RunConfig @@ -71,7 +71,7 @@ def _instructions(node: GraphNode, variables: dict[str, Any]) -> str | None: class LiteLLMNativeGraphOptions(TypedDict, total=False): """Options for :func:`to_litellm_agents`.""" - tool_handlers: dict[str, Callable[..., Any] | NativeTool] + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] context: LDContext """LaunchDarkly context used for tracking events. Required for LD telemetry.""" model_factory: ModelFactory diff --git a/packages/litellm-messages/src/launchdarkly_ai_litellm_messages/handler.py b/packages/litellm-messages/src/launchdarkly_ai_litellm_messages/handler.py index e3a386d0..e83554ee 100644 --- a/packages/litellm-messages/src/launchdarkly_ai_litellm_messages/handler.py +++ b/packages/litellm-messages/src/launchdarkly_ai_litellm_messages/handler.py @@ -3,7 +3,7 @@ import asyncio import inspect import json -from collections.abc import AsyncGenerator, Awaitable, Callable +from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping from typing import Any from opentelemetry import trace @@ -206,7 +206,8 @@ def _build_messages( def _build_tools( - config_value: AiConfigRep, tool_handlers: dict[str, Callable[..., Any] | NativeTool] + config_value: AiConfigRep, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool], ) -> list[dict[str, Any]]: return [ { @@ -367,7 +368,7 @@ async def _invoke_tool(handler: Any, arguments: dict[str, Any]) -> Any: async def _execute_tools( calls: list[Any], - handlers: dict[str, Any], + handlers: Mapping[str, Any], parent: Any, ended: set[int], *, @@ -456,7 +457,7 @@ def _create_litellm_messages_handler( async def call( config_value: AiConfigRep, user_input: str | None = None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: @@ -568,7 +569,7 @@ async def call( def stream( config_value: AiConfigRep, user_input: str | None = None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: @@ -596,7 +597,7 @@ async def _stream( completion: Completion, config_value: AiConfigRep, user_input: str | None, - handlers: dict[str, Any], + handlers: Mapping[str, Any], variables: dict[str, Any], history: list[dict[str, Any]] | None, *, diff --git a/packages/openai-agents/api-report/launchdarkly_ai_openai_agents.txt b/packages/openai-agents/api-report/launchdarkly_ai_openai_agents.txt index 9ac144c7..bd4025ad 100644 --- a/packages/openai-agents/api-report/launchdarkly_ai_openai_agents.txt +++ b/packages/openai-agents/api-report/launchdarkly_ai_openai_agents.txt @@ -4,5 +4,5 @@ openai_agents(config_key: str, user_input: str, context: LDContext, **kwargs: An openai_graph(key: str, **options: Any) -> Any OpenAINativeGraphOptions(TypedDict, total=False) [class] context: LDContext - tool_handlers: dict[str, Callable[..., Any] | NativeTool] + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] to_openai_agents(def_promise: Awaitable[GraphDefinition], opts: OpenAINativeGraphOptions | None = None) -> NativeGraphRunner diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py index 8844c2e0..29b5bed6 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py @@ -207,7 +207,7 @@ def _model_settings_parameters(config: AiConfigRep) -> dict[str, Any]: def _build_agent_tools( config_tools: dict[str, Any], - tool_handlers: dict[str, Callable[..., Any] | NativeTool], + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool], ) -> list[Any]: # Not filtered to the tools that have a registered handler, unlike the TypeScript SDK, which # excludes an unregistered tool from the catalog entirely. This difference predates the span @@ -285,7 +285,7 @@ def _prompt_to_text(prompt: str | list[dict[str, Any]]) -> str: def _build_agent_and_prompt( config: AiConfigRep, user_input: str | None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool], + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool], variables: dict[str, Any], history: list[dict[str, Any]] | None = None, ) -> tuple[Any, str | list[dict[str, Any]], str | None]: @@ -671,7 +671,7 @@ def _create_openai_agents_handler( async def _call_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: @@ -754,7 +754,7 @@ async def _call_impl( def _stream_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: @@ -789,7 +789,7 @@ def _stringify_output(value: Any) -> str: async def _stream_gen( config: AiConfigRep, user_input: str, - tool_handlers: dict[str, Callable[..., Any] | NativeTool], + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool], variables: dict[str, Any], history: list[dict[str, Any]] | None = None, *, diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py index cf3643c2..a91bb734 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py @@ -10,7 +10,7 @@ import re import time import uuid -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping from typing import Any, TypedDict from launchdarkly_ai_server import ( @@ -65,7 +65,7 @@ def _build_instructions(node: GraphNode, variables: dict[str, Any]) -> str | Non def _build_node_tools( node: GraphNode, - tool_handlers: dict[str, Callable[..., Any] | NativeTool], + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool], ) -> list[Any]: import importlib @@ -101,7 +101,7 @@ async def _execute(args: Any, _name: str = name) -> str: class OpenAINativeGraphOptions(TypedDict, total=False): """Options for :func:`to_openai_agents`.""" - tool_handlers: dict[str, Callable[..., Any] | NativeTool] + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] context: LDContext """LaunchDarkly context used for tracking events. Required for LD telemetry.""" @@ -149,7 +149,7 @@ async def invoke( if not root: raise ValueError(f'Graph "{def_obj.key}" has no root node') - tool_handlers: dict[str, Callable[..., Any] | NativeTool] = ( + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] = ( _opts.get("tool_handlers") or {} ) raw_ld_context = _opts.get("context") diff --git a/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py b/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py index bad7fe66..662bc9dd 100644 --- a/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py +++ b/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py @@ -408,7 +408,7 @@ def _create_openai_messages_handler( async def _call_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: @@ -579,7 +579,7 @@ async def _call_impl( def _stream_impl( config: AiConfigRep, user_input: str = "", - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: @@ -613,7 +613,7 @@ async def _stream_gen( client: Any, config: AiConfigRep, user_input: str, - tool_handlers: dict[str, Callable[..., Any] | NativeTool], + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool], variables: dict[str, Any], history: list[dict[str, Any]] | None = None, *, diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index fe1f0427..9ca641d0 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -746,7 +746,9 @@ def __init__(self) -> None: self.tool_display_names: dict[str, str] = {} -def _plan_tools(config: AiConfigRep, tool_handlers: dict[str, Any] | None) -> _ToolPlan: +def _plan_tools( + config: AiConfigRep, tool_handlers: Mapping[str, Any] | None +) -> _ToolPlan: """Partitions the offered tools into native pass-throughs and bridged callables. "Offered" is the request-scoped authorization boundary: a config tool key @@ -885,7 +887,7 @@ async def _stream_impl( self, config: AiConfigRep, user_input: str | None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None, variables: dict[str, Any] | None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: @@ -1022,7 +1024,7 @@ async def _call_impl( self, config: AiConfigRep, user_input: str | None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None, variables: dict[str, Any] | None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: diff --git a/packages/typesafe/src/launchdarkly_ai_typesafe/handler.py b/packages/typesafe/src/launchdarkly_ai_typesafe/handler.py index c9fec1d3..b7c0dcee 100644 --- a/packages/typesafe/src/launchdarkly_ai_typesafe/handler.py +++ b/packages/typesafe/src/launchdarkly_ai_typesafe/handler.py @@ -99,7 +99,7 @@ def create_typesafe_handler(*, capture_content: bool | None = None) -> ProviderH async def _call( config: AiConfigRep, user_input: str | None = None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: diff --git a/packages/vercel-agents/api-report/launchdarkly_ai_vercel_agents.txt b/packages/vercel-agents/api-report/launchdarkly_ai_vercel_agents.txt index c1cd27de..9674673a 100644 --- a/packages/vercel-agents/api-report/launchdarkly_ai_vercel_agents.txt +++ b/packages/vercel-agents/api-report/launchdarkly_ai_vercel_agents.txt @@ -7,4 +7,4 @@ VercelNativeGraphOptions(TypedDict, total=False) [class] context: LDContext model: ModelSource model_factory: ModelSource - tool_handlers: dict[str, Callable[..., Any] | NativeTool] + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] diff --git a/packages/vercel-agents/src/launchdarkly_ai_vercel_agents/handler.py b/packages/vercel-agents/src/launchdarkly_ai_vercel_agents/handler.py index 4b8ef437..05973c15 100644 --- a/packages/vercel-agents/src/launchdarkly_ai_vercel_agents/handler.py +++ b/packages/vercel-agents/src/launchdarkly_ai_vercel_agents/handler.py @@ -283,7 +283,7 @@ async def _call_tool(handler: Callable[..., Any], kwargs: dict[str, Any]) -> Any def build_agent_tools( definitions: dict[str, Any] | None, - handlers: dict[str, Any] | None, + handlers: Mapping[str, Any] | None, runtime: Any = None, *, parent: Any = None, @@ -518,7 +518,7 @@ def _create_vercel_agents_handler( async def run( cfg: AiConfigRep, user_input: str | None = None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: @@ -571,7 +571,7 @@ async def run( async def stream( cfg: AiConfigRep, user_input: str | None = None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: diff --git a/packages/vercel-agents/src/launchdarkly_ai_vercel_agents/native_graph.py b/packages/vercel-agents/src/launchdarkly_ai_vercel_agents/native_graph.py index a497caec..7aa8d00e 100644 --- a/packages/vercel-agents/src/launchdarkly_ai_vercel_agents/native_graph.py +++ b/packages/vercel-agents/src/launchdarkly_ai_vercel_agents/native_graph.py @@ -2,7 +2,7 @@ import time import uuid -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping from typing import Any, TypedDict import ai @@ -86,7 +86,7 @@ def _track( class VercelNativeGraphOptions(TypedDict, total=False): """Options for :func:`to_vercel_agents`.""" - tool_handlers: dict[str, Callable[..., Any] | NativeTool] + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] context: LDContext """LaunchDarkly context used for tracking events. Required for LD telemetry.""" model: ModelSource diff --git a/packages/vercel-messages/src/launchdarkly_ai_vercel_messages/handler.py b/packages/vercel-messages/src/launchdarkly_ai_vercel_messages/handler.py index e7a9f824..1dd94c05 100644 --- a/packages/vercel-messages/src/launchdarkly_ai_vercel_messages/handler.py +++ b/packages/vercel-messages/src/launchdarkly_ai_vercel_messages/handler.py @@ -268,7 +268,7 @@ async def _call_tool(handler: Callable[..., Any], kwargs: dict[str, Any]) -> Any def _tool_executors( - cfg: AiConfigRep, handlers: dict[str, Any] | None + cfg: AiConfigRep, handlers: Mapping[str, Any] | None ) -> dict[str, Callable[..., Any]]: executors: dict[str, Callable[..., Any]] = {} for name in cfg.get("tools") or {}: @@ -333,7 +333,7 @@ def _final_output(stream: Any) -> str: return json.dumps(output) -def _tools(cfg: AiConfigRep, handlers: dict[str, Any] | None) -> list[Any]: +def _tools(cfg: AiConfigRep, handlers: Mapping[str, Any] | None) -> list[Any]: result: list[Any] = [] for name, definition in (cfg.get("tools") or {}).items(): handler = (handlers or {}).get(name) @@ -417,7 +417,7 @@ async def _run_conversation( user_input: str | None, variables: dict[str, Any], history: list[dict[str, Any]] | None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None, parent: Any, *, structured: bool, @@ -541,7 +541,7 @@ def _create_vercel_messages_handler( async def invoke( cfg: AiConfigRep, user_input: str | None = None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: @@ -594,7 +594,7 @@ async def invoke( async def stream( cfg: AiConfigRep, user_input: str | None = None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + tool_handlers: Mapping[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: diff --git a/scripts/check_packaging.py b/scripts/check_packaging.py index daf50586..5f0f0d47 100644 --- a/scripts/check_packaging.py +++ b/scripts/check_packaging.py @@ -9,6 +9,10 @@ imports (``importlib.import_module("openai")`` inside a factory), which step 1 never reaches. Imports under ``except ImportError`` or ``TYPE_CHECKING`` are optional. +Then each declared extra gets a venv of its own, installed as ``[]``, +and the modules it promises in ``EXTRA_IMPORTS`` must import there. An extra missing +from ``EXTRA_IMPORTS`` fails the check, so a new extra cannot go unverified. + Catches what the workspace test run cannot, because the workspace installs every package's dependencies at once: an import the package does not declare, a declared range that will not install, an extra that does not deliver what it promises. @@ -21,24 +25,35 @@ from __future__ import annotations import ast +import os +import re import subprocess import sys import tempfile import tomllib -from dataclasses import dataclass +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field from pathlib import Path ROOT = Path(__file__).resolve().parent.parent -# The extras the README's install command uses, and what each must make importable. -EXTRAS = {"launchdarkly-ai-server": "[ld]", "launchdarkly-ai-python": "[ld]"} +# The extras the README's install command uses. +README_EXTRAS = {"launchdarkly-ai-server": ("ld",), "launchdarkly-ai-python": ("ld",)} +# What each declared extra must make importable. +_OTEL = ["opentelemetry.sdk", "opentelemetry.exporter.otlp.proto.http"] EXTRA_IMPORTS = { - "launchdarkly-ai-server": ["ldclient"], - "launchdarkly-ai-python": ["ldclient"], + ("launchdarkly-ai-server", "ld"): ["ldclient"], + ("launchdarkly-ai-server", "otel"): _OTEL, + ("launchdarkly-ai-python", "ld"): ["ldclient"], + ("launchdarkly-ai-python", "otel"): _OTEL, + ("launchdarkly-ai-langchain-agents", "anthropic"): ["langchain_anthropic"], + ("launchdarkly-ai-langchain-agents", "aws"): ["langchain_aws"], + ("launchdarkly-ai-langchain-messages", "openai"): ["langchain_openai"], + ("launchdarkly-ai-langchain-messages", "anthropic"): ["langchain_anthropic"], + ("launchdarkly-ai-langchain-messages", "aws"): ["langchain_aws"], } -# A uv override replaces every requirement on its name, extras included, so restate -# the extras a package's own extra requests on an internal dependency. -OVERRIDE_EXTRAS = {("launchdarkly-ai-python", "launchdarkly-ai-server"): "[ld]"} + +_REQUIREMENT = re.compile(r"\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[([^\]]*)\])?") _IMPORT_ERRORS = {"ImportError", "ModuleNotFoundError", "Exception", "BaseException"} @@ -50,6 +65,14 @@ getattr(module, attr) """ +# Third-party modules an extra installs: their ``__all__`` is not our contract, and may +# name lazy attributes that need further extras (``langchain_aws.ChatAnthropicBedrock``). +IMPORT_CHECK = """ +import importlib, sys +for name in sys.argv[1:]: + importlib.import_module(name) +""" + INSTALLED_CHECK = """ import importlib.util, sys missing = [name for name in sys.argv[1:] if importlib.util.find_spec(name) is None] @@ -65,6 +88,53 @@ class Package: """Modules whose ``__all__`` is the public contract.""" imports: list[str] """Top-level modules the source imports unconditionally.""" + dependencies: list[str] = field(default_factory=list) + optional: dict[str, list[str]] = field(default_factory=dict) + """``[project.optional-dependencies]``: extra name to its requirements.""" + + def override_extras(self, dep: str, extras: tuple[str, ...]) -> str: + """The extras this install requests on ``dep``, as ``[a,b]`` or ``""``. + + A uv override replaces every requirement on its name, extras included, so the + override for an internal dependency must restate them. + """ + requested: set[str] = set() + for req in [*self.dependencies, *(r for e in extras for r in self.optional[e])]: + m = _REQUIREMENT.match(req) + if m and m.group(1) == dep and m.group(2): + requested.update(x.strip() for x in m.group(2).split(",")) + return f"[{','.join(sorted(requested))}]" if requested else "" + + +@dataclass(frozen=True) +class Run: + package: Package + extras: tuple[str, ...] + """Extras to install; the README's for the base run, one declared extra otherwise.""" + base: bool + + @property + def label(self) -> str: + return self.package.name + (f"[{','.join(self.extras)}]" if self.extras else "") + + +def runs(found: list[Package]) -> list[Run]: + """The base install of every package, then one run per declared extra.""" + unmapped = [ + f"{p.name}[{e}]" + for p in found + for e in p.optional + if (p.name, e) not in EXTRA_IMPORTS + ] + if unmapped: + raise SystemExit(f"no EXTRA_IMPORTS entry for: {', '.join(unmapped)}") + base = [Run(p, README_EXTRAS.get(p.name, ()), base=True) for p in found] + return base + [ + Run(p, (e,), base=False) + for p in found + for e in p.optional + if (e,) != README_EXTRAS.get(p.name) + ] def _catches_import_error(handler: ast.ExceptHandler) -> bool: @@ -141,11 +211,14 @@ def packages() -> list[Package]: for p in config["tool"]["hatch"]["build"]["targets"]["wheel"]["packages"] ] reported = [p.stem for p in (pyproject.parent / "api-report").glob("*.txt")] + project = config["project"] found.append( Package( - name=config["project"]["name"], + name=project["name"], public=sorted({*top_level, *reported}), imports=source_imports(pyproject.parent / "src"), + dependencies=project.get("dependencies", []), + optional=project.get("optional-dependencies", {}), ) ) return found @@ -158,21 +231,26 @@ def wheel_for(dist: Path, name: str) -> Path: return matches[0] -def check(package: Package, dist: Path, internal: list[str], tmp: Path) -> bool: +def check(run: Run, dist: Path, internal: list[str], tmp: Path) -> tuple[bool, str]: + """Returns whether the run passed, and its output, buffered so parallel logs stay whole.""" + package = run.package name = package.name - venv = tmp / name + slug = run.label.replace("[", "-").replace("]", "").replace(",", "-") + venv = tmp / slug python = venv / "bin" / "python" - requirement = f"{name}{EXTRAS.get(name, '')} @ {wheel_for(dist, name).as_uri()}" + extras = f"[{','.join(run.extras)}]" if run.extras else "" + requirement = f"{name}{extras} @ {wheel_for(dist, name).as_uri()}" # Overriding the package being installed would drop its own extras. - overrides = tmp / f"{name}.overrides.txt" + overrides = tmp / f"{slug}.overrides.txt" overrides.write_text( "".join( - f"{dep}{OVERRIDE_EXTRAS.get((name, dep), '')} @ {wheel_for(dist, dep).as_uri()}\n" + f"{dep}{package.override_extras(dep, run.extras)} @ {wheel_for(dist, dep).as_uri()}\n" for dep in internal if dep != name ) ) - steps = [ + extra_imports = [m for e in run.extras for m in EXTRA_IMPORTS[(name, e)]] + install = [ ["uv", "venv", "--quiet", "--python", "3.12", str(venv)], [ "uv", @@ -185,23 +263,33 @@ def check(package: Package, dist: Path, internal: list[str], tmp: Path) -> bool: str(overrides), requirement, ], + ] + if not run.base: + # The base run already checked the package itself; this one checks the extra. + steps = [*install, [str(python), "-I", "-c", IMPORT_CHECK, *extra_imports]] + return _run_steps( + run.label, steps, f"ok {run.label}: {', '.join(extra_imports)}" + ) + steps = [ + *install, # -I: import from the installed wheel, never the source tree. [str(python), "-I", "-c", PUBLIC_CHECK, *package.public], - [ - str(python), - "-I", - "-c", - INSTALLED_CHECK, - *package.imports, - *EXTRA_IMPORTS.get(name, []), - ], + [str(python), "-I", "-c", IMPORT_CHECK, *extra_imports], + [str(python), "-I", "-c", INSTALLED_CHECK, *package.imports], ] + return _run_steps( + run.label, steps, f"ok {run.label}: {', '.join(package.public)}" + ) + + +def _run_steps(label: str, steps: list[list[str]], ok: str) -> tuple[bool, str]: + output = [] for step in steps: - if subprocess.run(step).returncode != 0: - print(f"FAIL {name}", file=sys.stderr) - return False - print(f"ok {name}: {', '.join(package.public)}") - return True + result = subprocess.run(step, capture_output=True, text=True) + output += [result.stdout, result.stderr] + if result.returncode != 0: + return False, "".join(output) + f"FAIL {label}\n" + return True, "".join(output) + ok + "\n" def main() -> int: @@ -209,11 +297,20 @@ def main() -> int: raise SystemExit(__doc__) dist = Path(sys.argv[1]).resolve() found = packages() - with tempfile.TemporaryDirectory() as tmp_dir: - internal = [p.name for p in found] - failed = [p.name for p in found if not check(p, dist, internal, Path(tmp_dir))] + internal = [p.name for p in found] + planned = runs(found) + failed = [] + with ( + tempfile.TemporaryDirectory() as tmp_dir, + ThreadPoolExecutor(max_workers=min(8, os.cpu_count() or 1)) as pool, + ): + results = pool.map(lambda r: check(r, dist, internal, Path(tmp_dir)), planned) + for run, (ok, output) in zip(planned, results, strict=True): + print(output, end="", flush=True) + if not ok: + failed.append(run.label) if failed: - print(f"{len(failed)} package(s) failed: {', '.join(failed)}", file=sys.stderr) + print(f"{len(failed)} check(s) failed: {', '.join(failed)}", file=sys.stderr) return 1 return 0 diff --git a/tests/test_check_packaging.py b/tests/test_check_packaging.py index c517cbe8..01f8381c 100644 --- a/tests/test_check_packaging.py +++ b/tests/test_check_packaging.py @@ -8,6 +8,8 @@ import textwrap from pathlib import Path +import pytest + from scripts import check_packaging @@ -77,3 +79,35 @@ def test_stdlib_and_relative_imports_are_skipped(tmp_path: Path) -> None: from .sub import other """ assert _scan(tmp_path, source) == [] + + +def test_every_declared_extra_is_checked() -> None: + planned = {run.label for run in check_packaging.runs(check_packaging.packages())} + assert "launchdarkly-ai-langchain-messages[anthropic]" in planned + assert "launchdarkly-ai-python[otel]" in planned + + +def test_an_extra_without_expected_imports_fails() -> None: + package = check_packaging.Package( + name="launchdarkly-ai-example", + public=[], + imports=[], + optional={"new": ["some-dist>=1"]}, + ) + with pytest.raises(SystemExit, match=r"launchdarkly-ai-example\[new\]"): + check_packaging.runs([package]) + + +def test_overrides_restate_extras_requested_on_an_internal_dependency() -> None: + package = check_packaging.Package( + name="launchdarkly-ai-python", + public=[], + imports=[], + dependencies=["launchdarkly-ai-server>=0.4.1"], + optional={ + "ld": ["launchdarkly-ai-server[ld]"], + "otel": ["launchdarkly-ai-server[otel]"], + }, + ) + assert package.override_extras("launchdarkly-ai-server", ("otel",)) == "[otel]" + assert package.override_extras("launchdarkly-ai-server", ()) == "" diff --git a/uv.lock b/uv.lock index 94a6c42e..a218c9ce 100644 --- a/uv.lock +++ b/uv.lock @@ -1322,7 +1322,7 @@ wheels = [ [[package]] name = "launchdarkly-ai-claude-agents" -version = "0.2.6" +version = "0.3.0" source = { editable = "packages/claude-agents" } dependencies = [ { name = "anthropic" }, @@ -1341,7 +1341,7 @@ requires-dist = [ [[package]] name = "launchdarkly-ai-claude-messages" -version = "0.2.5" +version = "0.3.0" source = { editable = "packages/claude-messages" } dependencies = [ { name = "anthropic" }, @@ -1358,7 +1358,7 @@ requires-dist = [ [[package]] name = "launchdarkly-ai-langchain-agents" -version = "0.2.6" +version = "0.3.0" source = { editable = "packages/langchain-agents" } dependencies = [ { name = "langchain-core" }, @@ -1390,7 +1390,7 @@ provides-extras = ["anthropic", "aws"] [[package]] name = "launchdarkly-ai-langchain-messages" -version = "0.2.5" +version = "0.3.0" source = { editable = "packages/langchain-messages" } dependencies = [ { name = "langchain-core" }, @@ -1422,7 +1422,7 @@ provides-extras = ["openai", "anthropic", "aws"] [[package]] name = "launchdarkly-ai-litellm-agents" -version = "0.1.1" +version = "0.2.0" source = { editable = "packages/litellm-agents" } dependencies = [ { name = "launchdarkly-ai-server" }, @@ -1439,7 +1439,7 @@ requires-dist = [ [[package]] name = "launchdarkly-ai-litellm-messages" -version = "0.1.1" +version = "0.2.0" source = { editable = "packages/litellm-messages" } dependencies = [ { name = "launchdarkly-ai-server" }, @@ -1456,7 +1456,7 @@ requires-dist = [ [[package]] name = "launchdarkly-ai-openai-agents" -version = "0.2.6" +version = "0.3.0" source = { editable = "packages/openai-agents" } dependencies = [ { name = "launchdarkly-ai-server" }, @@ -1475,7 +1475,7 @@ requires-dist = [ [[package]] name = "launchdarkly-ai-openai-messages" -version = "0.2.5" +version = "0.3.0" source = { editable = "packages/openai-messages" } dependencies = [ { name = "launchdarkly-ai-server" }, @@ -1492,7 +1492,7 @@ requires-dist = [ [[package]] name = "launchdarkly-ai-opencode-agents" -version = "0.2.0" +version = "0.3.0" source = { editable = "packages/opencode-agents" } dependencies = [ { name = "httpx" }, @@ -1509,7 +1509,7 @@ requires-dist = [ [[package]] name = "launchdarkly-ai-python" -version = "0.3.0" +version = "0.4.0" source = { editable = "packages/ai" } dependencies = [ { name = "launchdarkly-ai-server" }, @@ -1533,7 +1533,7 @@ provides-extras = ["otel", "ld"] [[package]] name = "launchdarkly-ai-server" -version = "0.4.0" +version = "0.5.0" source = { editable = "packages/client" } dependencies = [ { name = "opentelemetry-api" }, @@ -1559,7 +1559,7 @@ provides-extras = ["otel", "ld"] [[package]] name = "launchdarkly-ai-typesafe" -version = "0.1.1" +version = "0.2.0" source = { editable = "packages/typesafe" } dependencies = [ { name = "launchdarkly-ai-server" }, @@ -1576,7 +1576,7 @@ requires-dist = [ [[package]] name = "launchdarkly-ai-vercel-agents" -version = "0.1.1" +version = "0.2.0" source = { editable = "packages/vercel-agents" } dependencies = [ { name = "ai" }, @@ -1593,7 +1593,7 @@ requires-dist = [ [[package]] name = "launchdarkly-ai-vercel-messages" -version = "0.1.1" +version = "0.2.0" source = { editable = "packages/vercel-messages" } dependencies = [ { name = "ai" }, From 49bbdde6f30dc1743c890a970108e24702256684 Mon Sep 17 00:00:00 2001 From: Jeff Dupont Date: Wed, 28 Oct 2026 15:29:58 -0700 Subject: [PATCH 6/9] fix(AIC-3598): check imports against declared requirements, core without extras - check_packaging: an unconditional import must come from a distribution the package declares. Installed is no longer enough: a module another dependency pulls in fails. Base runs install with no extras, so core is proven to load without launchdarkly-server-sdk; [ld] is now an extra run like the others. - Declare what the stricter check found: pydantic in openai-agents, litellm-agents, vercel-agents and vercel-messages; openai in litellm-agents. - Internal pins: launchdarkly-ai-server>=1.0.0,<2 everywhere. The native graph adapters import NativeGraphRunner, which published server 0.5.0 lacks. - README states the floor rule spec 5.3 uses: same major as the tested version for 1.x+, same minor for 0.x. - Tests: core is checked without extras, an undeclared dynamic import fails. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- packages/ai/pyproject.toml | 6 +- packages/claude-agents/pyproject.toml | 2 +- packages/claude-messages/pyproject.toml | 2 +- packages/langchain-agents/pyproject.toml | 2 +- packages/langchain-messages/pyproject.toml | 2 +- packages/litellm-agents/pyproject.toml | 4 +- packages/litellm-messages/pyproject.toml | 2 +- packages/openai-agents/pyproject.toml | 3 +- packages/openai-messages/pyproject.toml | 2 +- packages/opencode-agents/pyproject.toml | 2 +- packages/typesafe/pyproject.toml | 2 +- packages/vercel-agents/pyproject.toml | 3 +- packages/vercel-messages/pyproject.toml | 3 +- scripts/check_packaging.py | 89 ++++++++++++++++------ tests/test_check_packaging.py | 40 ++++++++++ uv.lock | 10 +++ 17 files changed, 137 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index e7181572..8c1ad273 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Tier 0 — Core Client (launchdarkly-ai-server) From 1.0.0 every package in this SDK is released at the same version, so install them at matching versions. Each package depends on `launchdarkly-ai-server` within the same major (`>=1.0,<2`), and a breaking change in any package is a new major for all of them. Names exported from `launchdarkly_ai_server.experimental` are the exception: they may change in a minor release. -Third-party dependency floors are the versions CI tests against, not the oldest release that might work. A floor moves down only when the test suite runs at the lower version. +Every third-party floor sits on the release line CI tests: the same major as the tested version for a 1.x or later library, the same minor for a 0.x one, where a minor release may break. A floor that was below that line was raised to the tested version; a floor already on it stayed where it was. ## Quick Start diff --git a/packages/ai/pyproject.toml b/packages/ai/pyproject.toml index 9a0a25b1..4edbe8a8 100644 --- a/packages/ai/pyproject.toml +++ b/packages/ai/pyproject.toml @@ -2,7 +2,7 @@ name = "launchdarkly-ai-python" version = "0.4.0" requires-python = ">=3.12" -dependencies = ["launchdarkly-ai-server"] +dependencies = ["launchdarkly-ai-server>=1.0.0,<2"] description = "LaunchDarkly AI SDK for Python — convenience barrel re-export" readme = "README.md" license = "Apache-2.0" @@ -23,8 +23,8 @@ Repository = "https://github.com/launchdarkly/python-ai-sdk" "Bug Tracker" = "https://github.com/launchdarkly/python-ai-sdk/issues" [project.optional-dependencies] -otel = ["launchdarkly-ai-server[otel]"] -ld = ["launchdarkly-ai-server[ld]"] +otel = ["launchdarkly-ai-server[otel]>=1.0.0,<2"] +ld = ["launchdarkly-ai-server[ld]>=1.0.0,<2"] [build-system] requires = ["hatchling>=1.32,<2"] diff --git a/packages/claude-agents/pyproject.toml b/packages/claude-agents/pyproject.toml index 41e3e322..acdea6ed 100644 --- a/packages/claude-agents/pyproject.toml +++ b/packages/claude-agents/pyproject.toml @@ -3,7 +3,7 @@ name = "launchdarkly-ai-claude-agents" version = "0.3.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=0.4.1", + "launchdarkly-ai-server>=1.0.0,<2", "opentelemetry-api>=1.25", "anthropic>=0.113", "claude-agent-sdk>=0.2.121", diff --git a/packages/claude-messages/pyproject.toml b/packages/claude-messages/pyproject.toml index 6af2252f..217ed648 100644 --- a/packages/claude-messages/pyproject.toml +++ b/packages/claude-messages/pyproject.toml @@ -3,7 +3,7 @@ name = "launchdarkly-ai-claude-messages" version = "0.3.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=0.4.1", + "launchdarkly-ai-server>=1.0.0,<2", "opentelemetry-api>=1.25", "anthropic>=0.113", ] diff --git a/packages/langchain-agents/pyproject.toml b/packages/langchain-agents/pyproject.toml index 3f77cf39..c43fe556 100644 --- a/packages/langchain-agents/pyproject.toml +++ b/packages/langchain-agents/pyproject.toml @@ -3,7 +3,7 @@ name = "launchdarkly-ai-langchain-agents" version = "0.3.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=0.4.1", + "launchdarkly-ai-server>=1.0.0,<2", "opentelemetry-api>=1.25", "langchain-core>=1.6", "langgraph>=1.2", diff --git a/packages/langchain-messages/pyproject.toml b/packages/langchain-messages/pyproject.toml index 51f36c08..ec1982f8 100644 --- a/packages/langchain-messages/pyproject.toml +++ b/packages/langchain-messages/pyproject.toml @@ -3,7 +3,7 @@ name = "launchdarkly-ai-langchain-messages" version = "0.3.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=0.4.1", + "launchdarkly-ai-server>=1.0.0,<2", "opentelemetry-api>=1.25", "langchain-core>=1.6", ] diff --git a/packages/litellm-agents/pyproject.toml b/packages/litellm-agents/pyproject.toml index 9253f7de..ee198467 100644 --- a/packages/litellm-agents/pyproject.toml +++ b/packages/litellm-agents/pyproject.toml @@ -3,9 +3,11 @@ name = "launchdarkly-ai-litellm-agents" version = "0.2.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=0.4.1", + "launchdarkly-ai-server>=1.0.0,<2", "openai-agents[litellm]>=0.17", "opentelemetry-api>=1.25", + "pydantic>=2.13", + "openai>=2.44", ] description = "LiteLLM-backed OpenAI Agents handler for LaunchDarkly AI SDK" readme = "README.md" diff --git a/packages/litellm-messages/pyproject.toml b/packages/litellm-messages/pyproject.toml index 26245b2e..08f6a35e 100644 --- a/packages/litellm-messages/pyproject.toml +++ b/packages/litellm-messages/pyproject.toml @@ -3,7 +3,7 @@ name = "launchdarkly-ai-litellm-messages" version = "0.2.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=0.4.1", + "launchdarkly-ai-server>=1.0.0,<2", "litellm>=1.83.0", "opentelemetry-api>=1.25", ] diff --git a/packages/openai-agents/pyproject.toml b/packages/openai-agents/pyproject.toml index 1eaed284..95c340d2 100644 --- a/packages/openai-agents/pyproject.toml +++ b/packages/openai-agents/pyproject.toml @@ -3,8 +3,9 @@ name = "launchdarkly-ai-openai-agents" version = "0.3.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=0.4.1", + "launchdarkly-ai-server>=1.0.0,<2", "opentelemetry-api>=1.25", + "pydantic>=2.13", "openai>=2.44", "openai-agents>=0.17", ] diff --git a/packages/openai-messages/pyproject.toml b/packages/openai-messages/pyproject.toml index 0bd046fa..581393fc 100644 --- a/packages/openai-messages/pyproject.toml +++ b/packages/openai-messages/pyproject.toml @@ -3,7 +3,7 @@ name = "launchdarkly-ai-openai-messages" version = "0.3.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=0.4.1", + "launchdarkly-ai-server>=1.0.0,<2", "opentelemetry-api>=1.25", "openai>=2.44", ] diff --git a/packages/opencode-agents/pyproject.toml b/packages/opencode-agents/pyproject.toml index 54a85058..8ce265f6 100644 --- a/packages/opencode-agents/pyproject.toml +++ b/packages/opencode-agents/pyproject.toml @@ -3,7 +3,7 @@ name = "launchdarkly-ai-opencode-agents" version = "0.3.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=0.4.1", + "launchdarkly-ai-server>=1.0.0,<2", "httpx>=0.28", "opentelemetry-api>=1.25", ] diff --git a/packages/typesafe/pyproject.toml b/packages/typesafe/pyproject.toml index 85b76077..f008b521 100644 --- a/packages/typesafe/pyproject.toml +++ b/packages/typesafe/pyproject.toml @@ -3,7 +3,7 @@ name = "launchdarkly-ai-typesafe" version = "0.2.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server", + "launchdarkly-ai-server>=1.0.0,<2", "opentelemetry-api>=1.25", "typesafe-sdk>=0.7", ] diff --git a/packages/vercel-agents/pyproject.toml b/packages/vercel-agents/pyproject.toml index 14e17512..766f3a37 100644 --- a/packages/vercel-agents/pyproject.toml +++ b/packages/vercel-agents/pyproject.toml @@ -3,8 +3,9 @@ name = "launchdarkly-ai-vercel-agents" version = "0.2.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=0.4.1", + "launchdarkly-ai-server>=1.0.0,<2", "opentelemetry-api>=1.25", + "pydantic>=2.13", "ai>=0.7,<0.8", ] description = "Vercel AI SDK agent handler for LaunchDarkly AI SDK" diff --git a/packages/vercel-messages/pyproject.toml b/packages/vercel-messages/pyproject.toml index d281f162..99d735d8 100644 --- a/packages/vercel-messages/pyproject.toml +++ b/packages/vercel-messages/pyproject.toml @@ -3,8 +3,9 @@ name = "launchdarkly-ai-vercel-messages" version = "0.2.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=0.4.1", + "launchdarkly-ai-server>=1.0.0,<2", "opentelemetry-api>=1.25", + "pydantic>=2.13", "ai>=0.7,<0.8", ] description = "Vercel AI SDK messages handler for LaunchDarkly AI SDK" diff --git a/scripts/check_packaging.py b/scripts/check_packaging.py index 5f0f0d47..b392df1c 100644 --- a/scripts/check_packaging.py +++ b/scripts/check_packaging.py @@ -2,12 +2,17 @@ Usage: uv run python scripts/check_packaging.py -For each package, in a fresh virtualenv with only what its install command pulls in: +For each package, in a fresh virtualenv with the package alone, no extras: 1. every public module imports, and every name in its ``__all__`` resolves; -2. every module its source imports unconditionally is installed. That includes lazy - imports (``importlib.import_module("openai")`` inside a factory), which step 1 never - reaches. Imports under ``except ImportError`` or ``TYPE_CHECKING`` are optional. +2. every module its source imports unconditionally comes from a distribution the + package declares. Installed is not enough: a module another dependency happens to + pull in still fails. That includes lazy imports (``importlib.import_module("openai")`` + inside a factory), which step 1 never reaches. Imports under ``except ImportError`` + or ``TYPE_CHECKING`` are optional. + +Installing with no extras is what proves the optional dependencies stay optional: core +loads without ``launchdarkly-server-sdk`` and the OpenTelemetry SDK. Then each declared extra gets a venv of its own, installed as ``[]``, and the modules it promises in ``EXTRA_IMPORTS`` must import there. An extra missing @@ -25,6 +30,7 @@ from __future__ import annotations import ast +import json import os import re import subprocess @@ -37,8 +43,6 @@ ROOT = Path(__file__).resolve().parent.parent -# The extras the README's install command uses. -README_EXTRAS = {"launchdarkly-ai-server": ("ld",), "launchdarkly-ai-python": ("ld",)} # What each declared extra must make importable. _OTEL = ["opentelemetry.sdk", "opentelemetry.exporter.otlp.proto.http"] EXTRA_IMPORTS = { @@ -73,14 +77,36 @@ importlib.import_module(name) """ -INSTALLED_CHECK = """ -import importlib.util, sys -missing = [name for name in sys.argv[1:] if importlib.util.find_spec(name) is None] -if missing: - sys.exit("imported but not installed: " + ", ".join(missing)) +# Prints which installed distributions provide each module, so the caller can compare +# them with what the package declares. ``null`` means the module is not installed. +PROVIDERS = """ +import importlib.metadata, importlib.util, json, sys +dists = importlib.metadata.packages_distributions() +print(json.dumps({ + name: dists.get(name, []) if importlib.util.find_spec(name) else None + for name in sys.argv[1:] +})) """ +def _normalize(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def undeclared_imports( + providers: dict[str, list[str] | None], declared: set[str] +) -> list[str]: + """Each import that no declared distribution provides, with the reason.""" + problems = [] + for module, dists in sorted(providers.items()): + if dists is None: + problems.append(f"{module} (not installed)") + elif not {_normalize(d) for d in dists} & declared: + found = ", ".join(sorted(dists)) or "no distribution" + problems.append(f"{module} (from {found}, not declared)") + return problems + + @dataclass(frozen=True) class Package: name: str @@ -105,12 +131,21 @@ def override_extras(self, dep: str, extras: tuple[str, ...]) -> str: requested.update(x.strip() for x in m.group(2).split(",")) return f"[{','.join(sorted(requested))}]" if requested else "" + def declared(self) -> set[str]: + """The package itself and its required dependencies, normalized.""" + names = {_normalize(self.name)} + for req in self.dependencies: + m = _REQUIREMENT.match(req) + if m: + names.add(_normalize(m.group(1))) + return names + @dataclass(frozen=True) class Run: package: Package extras: tuple[str, ...] - """Extras to install; the README's for the base run, one declared extra otherwise.""" + """Extras to install: none for the base run, one declared extra otherwise.""" base: bool @property @@ -128,13 +163,8 @@ def runs(found: list[Package]) -> list[Run]: ] if unmapped: raise SystemExit(f"no EXTRA_IMPORTS entry for: {', '.join(unmapped)}") - base = [Run(p, README_EXTRAS.get(p.name, ()), base=True) for p in found] - return base + [ - Run(p, (e,), base=False) - for p in found - for e in p.optional - if (e,) != README_EXTRAS.get(p.name) - ] + base = [Run(p, (), base=True) for p in found] + return base + [Run(p, (e,), base=False) for p in found for e in p.optional] def _catches_import_error(handler: ast.ExceptHandler) -> bool: @@ -274,12 +304,25 @@ def check(run: Run, dist: Path, internal: list[str], tmp: Path) -> tuple[bool, s *install, # -I: import from the installed wheel, never the source tree. [str(python), "-I", "-c", PUBLIC_CHECK, *package.public], - [str(python), "-I", "-c", IMPORT_CHECK, *extra_imports], - [str(python), "-I", "-c", INSTALLED_CHECK, *package.imports], ] - return _run_steps( - run.label, steps, f"ok {run.label}: {', '.join(package.public)}" + ok, output = _run_steps(run.label, steps, "") + if not ok: + return ok, output + result = subprocess.run( + [str(python), "-I", "-c", PROVIDERS, *package.imports], + capture_output=True, + text=True, ) + if result.returncode != 0: + return False, output + result.stderr + f"FAIL {run.label}\n" + problems = undeclared_imports(json.loads(result.stdout), package.declared()) + if problems: + return False, ( + output + + f"imported but not declared: {'; '.join(problems)}\n" + + f"FAIL {run.label}\n" + ) + return True, output + f"ok {run.label}: {', '.join(package.public)}\n" def _run_steps(label: str, steps: list[list[str]], ok: str) -> tuple[bool, str]: diff --git a/tests/test_check_packaging.py b/tests/test_check_packaging.py index 01f8381c..53465005 100644 --- a/tests/test_check_packaging.py +++ b/tests/test_check_packaging.py @@ -85,6 +85,46 @@ def test_every_declared_extra_is_checked() -> None: planned = {run.label for run in check_packaging.runs(check_packaging.packages())} assert "launchdarkly-ai-langchain-messages[anthropic]" in planned assert "launchdarkly-ai-python[otel]" in planned + assert "launchdarkly-ai-server[ld]" in planned + + +def test_core_is_checked_without_extras() -> None: + planned = check_packaging.runs(check_packaging.packages()) + base = {run.package.name: run.extras for run in planned if run.base} + assert base["launchdarkly-ai-server"] == () + assert base["launchdarkly-ai-python"] == () + + +def test_an_undeclared_dynamic_import_fails(tmp_path: Path) -> None: + source = """ + import importlib + + def factory(): + return importlib.import_module("langchain_anthropic") + """ + package = check_packaging.Package( + name="launchdarkly-ai-example", + public=[], + imports=_scan(tmp_path, source), + dependencies=["langchain-core>=1.6"], + ) + # Installed, because another dependency pulled it in, but not declared. + providers = {"langchain_anthropic": ["langchain-anthropic"]} + assert check_packaging.undeclared_imports(providers, package.declared()) == [ + "langchain_anthropic (from langchain-anthropic, not declared)" + ] + + +def test_declared_and_missing_imports() -> None: + declared = {"launchdarkly-ai-example", "typing-extensions"} + providers: dict[str, list[str] | None] = { + "launchdarkly_ai_example": ["launchdarkly_ai_example"], + "typing_extensions": ["typing_extensions"], + "absent": None, + } + assert check_packaging.undeclared_imports(providers, declared) == [ + "absent (not installed)" + ] def test_an_extra_without_expected_imports_fails() -> None: diff --git a/uv.lock b/uv.lock index a218c9ce..4e831b3d 100644 --- a/uv.lock +++ b/uv.lock @@ -1426,15 +1426,19 @@ version = "0.2.0" source = { editable = "packages/litellm-agents" } dependencies = [ { name = "launchdarkly-ai-server" }, + { name = "openai" }, { name = "openai-agents", extra = ["litellm"] }, { name = "opentelemetry-api" }, + { name = "pydantic" }, ] [package.metadata] requires-dist = [ { name = "launchdarkly-ai-server", editable = "packages/client" }, + { name = "openai", specifier = ">=2.44" }, { name = "openai-agents", extras = ["litellm"], specifier = ">=0.17" }, { name = "opentelemetry-api", specifier = ">=1.25" }, + { name = "pydantic", specifier = ">=2.13" }, ] [[package]] @@ -1463,6 +1467,7 @@ dependencies = [ { name = "openai" }, { name = "openai-agents" }, { name = "opentelemetry-api" }, + { name = "pydantic" }, ] [package.metadata] @@ -1471,6 +1476,7 @@ requires-dist = [ { name = "openai", specifier = ">=2.44" }, { name = "openai-agents", specifier = ">=0.17" }, { name = "opentelemetry-api", specifier = ">=1.25" }, + { name = "pydantic", specifier = ">=2.13" }, ] [[package]] @@ -1582,6 +1588,7 @@ dependencies = [ { name = "ai" }, { name = "launchdarkly-ai-server" }, { name = "opentelemetry-api" }, + { name = "pydantic" }, ] [package.metadata] @@ -1589,6 +1596,7 @@ requires-dist = [ { name = "ai", specifier = ">=0.7,<0.8" }, { name = "launchdarkly-ai-server", editable = "packages/client" }, { name = "opentelemetry-api", specifier = ">=1.25" }, + { name = "pydantic", specifier = ">=2.13" }, ] [[package]] @@ -1599,6 +1607,7 @@ dependencies = [ { name = "ai" }, { name = "launchdarkly-ai-server" }, { name = "opentelemetry-api" }, + { name = "pydantic" }, ] [package.metadata] @@ -1606,6 +1615,7 @@ requires-dist = [ { name = "ai", specifier = ">=0.7,<0.8" }, { name = "launchdarkly-ai-server", editable = "packages/client" }, { name = "opentelemetry-api", specifier = ">=1.25" }, + { name = "pydantic", specifier = ">=2.13" }, ] [[package]] From 4d7b99ecd3f8d4c0e87f14a2b3f366663c88683c Mon Sep 17 00:00:00 2001 From: Jeff Dupont Date: Wed, 28 Oct 2026 15:41:53 -0700 Subject: [PATCH 7/9] fix(AIC-3598): pin internal deps to >=0.6,<2 so either release order resolves >=1.0.0,<2 could not resolve if a release went out before release-as 1.0.0 lands (Bugbot). >=0.6 still excludes server 0.5.0, which lacks NativeGraphRunner; this PR's breaking change makes core's next 0.x release 0.6.0, and 1.0.0 satisfies it too. #242 tightens it to >=1.0.0,<2 at the cut. Co-Authored-By: Claude Opus 5.5 --- packages/ai/pyproject.toml | 6 +++--- packages/claude-agents/pyproject.toml | 2 +- packages/claude-messages/pyproject.toml | 2 +- packages/langchain-agents/pyproject.toml | 2 +- packages/langchain-messages/pyproject.toml | 2 +- packages/litellm-agents/pyproject.toml | 2 +- packages/litellm-messages/pyproject.toml | 2 +- packages/openai-agents/pyproject.toml | 2 +- packages/openai-messages/pyproject.toml | 2 +- packages/opencode-agents/pyproject.toml | 2 +- packages/typesafe/pyproject.toml | 2 +- packages/vercel-agents/pyproject.toml | 2 +- packages/vercel-messages/pyproject.toml | 2 +- 13 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/ai/pyproject.toml b/packages/ai/pyproject.toml index 4edbe8a8..e53ac450 100644 --- a/packages/ai/pyproject.toml +++ b/packages/ai/pyproject.toml @@ -2,7 +2,7 @@ name = "launchdarkly-ai-python" version = "0.4.0" requires-python = ">=3.12" -dependencies = ["launchdarkly-ai-server>=1.0.0,<2"] +dependencies = ["launchdarkly-ai-server>=0.6,<2"] description = "LaunchDarkly AI SDK for Python — convenience barrel re-export" readme = "README.md" license = "Apache-2.0" @@ -23,8 +23,8 @@ Repository = "https://github.com/launchdarkly/python-ai-sdk" "Bug Tracker" = "https://github.com/launchdarkly/python-ai-sdk/issues" [project.optional-dependencies] -otel = ["launchdarkly-ai-server[otel]>=1.0.0,<2"] -ld = ["launchdarkly-ai-server[ld]>=1.0.0,<2"] +otel = ["launchdarkly-ai-server[otel]>=0.6,<2"] +ld = ["launchdarkly-ai-server[ld]>=0.6,<2"] [build-system] requires = ["hatchling>=1.32,<2"] diff --git a/packages/claude-agents/pyproject.toml b/packages/claude-agents/pyproject.toml index acdea6ed..d37bcdad 100644 --- a/packages/claude-agents/pyproject.toml +++ b/packages/claude-agents/pyproject.toml @@ -3,7 +3,7 @@ name = "launchdarkly-ai-claude-agents" version = "0.3.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=1.0.0,<2", + "launchdarkly-ai-server>=0.6,<2", "opentelemetry-api>=1.25", "anthropic>=0.113", "claude-agent-sdk>=0.2.121", diff --git a/packages/claude-messages/pyproject.toml b/packages/claude-messages/pyproject.toml index 217ed648..8044191c 100644 --- a/packages/claude-messages/pyproject.toml +++ b/packages/claude-messages/pyproject.toml @@ -3,7 +3,7 @@ name = "launchdarkly-ai-claude-messages" version = "0.3.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=1.0.0,<2", + "launchdarkly-ai-server>=0.6,<2", "opentelemetry-api>=1.25", "anthropic>=0.113", ] diff --git a/packages/langchain-agents/pyproject.toml b/packages/langchain-agents/pyproject.toml index c43fe556..ec4fab2c 100644 --- a/packages/langchain-agents/pyproject.toml +++ b/packages/langchain-agents/pyproject.toml @@ -3,7 +3,7 @@ name = "launchdarkly-ai-langchain-agents" version = "0.3.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=1.0.0,<2", + "launchdarkly-ai-server>=0.6,<2", "opentelemetry-api>=1.25", "langchain-core>=1.6", "langgraph>=1.2", diff --git a/packages/langchain-messages/pyproject.toml b/packages/langchain-messages/pyproject.toml index ec1982f8..457ba23a 100644 --- a/packages/langchain-messages/pyproject.toml +++ b/packages/langchain-messages/pyproject.toml @@ -3,7 +3,7 @@ name = "launchdarkly-ai-langchain-messages" version = "0.3.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=1.0.0,<2", + "launchdarkly-ai-server>=0.6,<2", "opentelemetry-api>=1.25", "langchain-core>=1.6", ] diff --git a/packages/litellm-agents/pyproject.toml b/packages/litellm-agents/pyproject.toml index ee198467..19aca277 100644 --- a/packages/litellm-agents/pyproject.toml +++ b/packages/litellm-agents/pyproject.toml @@ -3,7 +3,7 @@ name = "launchdarkly-ai-litellm-agents" version = "0.2.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=1.0.0,<2", + "launchdarkly-ai-server>=0.6,<2", "openai-agents[litellm]>=0.17", "opentelemetry-api>=1.25", "pydantic>=2.13", diff --git a/packages/litellm-messages/pyproject.toml b/packages/litellm-messages/pyproject.toml index 08f6a35e..e9e2ac05 100644 --- a/packages/litellm-messages/pyproject.toml +++ b/packages/litellm-messages/pyproject.toml @@ -3,7 +3,7 @@ name = "launchdarkly-ai-litellm-messages" version = "0.2.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=1.0.0,<2", + "launchdarkly-ai-server>=0.6,<2", "litellm>=1.83.0", "opentelemetry-api>=1.25", ] diff --git a/packages/openai-agents/pyproject.toml b/packages/openai-agents/pyproject.toml index 95c340d2..8d621a91 100644 --- a/packages/openai-agents/pyproject.toml +++ b/packages/openai-agents/pyproject.toml @@ -3,7 +3,7 @@ name = "launchdarkly-ai-openai-agents" version = "0.3.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=1.0.0,<2", + "launchdarkly-ai-server>=0.6,<2", "opentelemetry-api>=1.25", "pydantic>=2.13", "openai>=2.44", diff --git a/packages/openai-messages/pyproject.toml b/packages/openai-messages/pyproject.toml index 581393fc..4b4cc93e 100644 --- a/packages/openai-messages/pyproject.toml +++ b/packages/openai-messages/pyproject.toml @@ -3,7 +3,7 @@ name = "launchdarkly-ai-openai-messages" version = "0.3.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=1.0.0,<2", + "launchdarkly-ai-server>=0.6,<2", "opentelemetry-api>=1.25", "openai>=2.44", ] diff --git a/packages/opencode-agents/pyproject.toml b/packages/opencode-agents/pyproject.toml index 8ce265f6..0c6140ce 100644 --- a/packages/opencode-agents/pyproject.toml +++ b/packages/opencode-agents/pyproject.toml @@ -3,7 +3,7 @@ name = "launchdarkly-ai-opencode-agents" version = "0.3.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=1.0.0,<2", + "launchdarkly-ai-server>=0.6,<2", "httpx>=0.28", "opentelemetry-api>=1.25", ] diff --git a/packages/typesafe/pyproject.toml b/packages/typesafe/pyproject.toml index f008b521..d4288d5e 100644 --- a/packages/typesafe/pyproject.toml +++ b/packages/typesafe/pyproject.toml @@ -3,7 +3,7 @@ name = "launchdarkly-ai-typesafe" version = "0.2.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=1.0.0,<2", + "launchdarkly-ai-server>=0.6,<2", "opentelemetry-api>=1.25", "typesafe-sdk>=0.7", ] diff --git a/packages/vercel-agents/pyproject.toml b/packages/vercel-agents/pyproject.toml index 766f3a37..35778c37 100644 --- a/packages/vercel-agents/pyproject.toml +++ b/packages/vercel-agents/pyproject.toml @@ -3,7 +3,7 @@ name = "launchdarkly-ai-vercel-agents" version = "0.2.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=1.0.0,<2", + "launchdarkly-ai-server>=0.6,<2", "opentelemetry-api>=1.25", "pydantic>=2.13", "ai>=0.7,<0.8", diff --git a/packages/vercel-messages/pyproject.toml b/packages/vercel-messages/pyproject.toml index 99d735d8..c642e544 100644 --- a/packages/vercel-messages/pyproject.toml +++ b/packages/vercel-messages/pyproject.toml @@ -3,7 +3,7 @@ name = "launchdarkly-ai-vercel-messages" version = "0.2.0" requires-python = ">=3.12" dependencies = [ - "launchdarkly-ai-server>=1.0.0,<2", + "launchdarkly-ai-server>=0.6,<2", "opentelemetry-api>=1.25", "pydantic>=2.13", "ai>=0.7,<0.8", From f3fbe8a891f61cb5e5b95bfe0850d84b3d0c0e1d Mon Sep 17 00:00:00 2001 From: Jeff Dupont Date: Thu, 29 Oct 2026 08:43:42 -0700 Subject: [PATCH 8/9] fix(AIC-3598): resolve dotted imports to their own distribution; langchain-openai is a dependency Review nits from XieX: - check_packaging keeps each import's full dotted name and maps it to the distribution that owns its file, so a namespace package such as opentelemetry.sdk needs opentelemetry-sdk declared, not any opentelemetry-*. It found langgraph.prebuilt (from langgraph-prebuilt) undeclared in langchain-agents; declared at >=1.1. - langchain-messages depends on langchain-openai, its default model, as langchain-agents does, so a bare install works; the [openai] extra is gone. - The test module docstring no longer cites the private spec. Co-Authored-By: Claude Opus 5.5 --- packages/langchain-agents/pyproject.toml | 1 + packages/langchain-messages/README.md | 3 +- packages/langchain-messages/pyproject.toml | 2 +- .../handler.py | 8 +---- scripts/check_packaging.py | 36 ++++++++++++++----- tests/test_check_packaging.py | 16 +++++++-- uv.lock | 10 +++--- 7 files changed, 50 insertions(+), 26 deletions(-) diff --git a/packages/langchain-agents/pyproject.toml b/packages/langchain-agents/pyproject.toml index ec4fab2c..9dc7c3e1 100644 --- a/packages/langchain-agents/pyproject.toml +++ b/packages/langchain-agents/pyproject.toml @@ -7,6 +7,7 @@ dependencies = [ "opentelemetry-api>=1.25", "langchain-core>=1.6", "langgraph>=1.2", + "langgraph-prebuilt>=1.1", "langchain-openai>=1.3", ] description = "LangChain/LangGraph agent handler for LaunchDarkly AI SDK" diff --git a/packages/langchain-messages/README.md b/packages/langchain-messages/README.md index 2ed7532e..fc8d5c8f 100644 --- a/packages/langchain-messages/README.md +++ b/packages/langchain-messages/README.md @@ -12,10 +12,9 @@ pip install launchdarkly-ai-server launchdarkly-ai-langchain-messages The default model is `ChatOpenAI`, so set `OPENAI_API_KEY` unless you pass a custom `BaseChatModel`. -Install the extra for each provider your AI configs use: +`langchain-openai` is a dependency, so the default model works with the install above. For AI configs whose provider is Anthropic or Bedrock, install the matching extra: ```bash -pip install "launchdarkly-ai-langchain-messages[openai]" # langchain-openai (the default model) pip install "launchdarkly-ai-langchain-messages[anthropic]" # langchain-anthropic pip install "launchdarkly-ai-langchain-messages[aws]" # langchain-aws (Bedrock) ``` diff --git a/packages/langchain-messages/pyproject.toml b/packages/langchain-messages/pyproject.toml index 457ba23a..d5e866aa 100644 --- a/packages/langchain-messages/pyproject.toml +++ b/packages/langchain-messages/pyproject.toml @@ -6,6 +6,7 @@ dependencies = [ "launchdarkly-ai-server>=0.6,<2", "opentelemetry-api>=1.25", "langchain-core>=1.6", + "langchain-openai>=1.3", ] description = "LangChain messages handler for LaunchDarkly AI SDK" readme = "README.md" @@ -27,7 +28,6 @@ Repository = "https://github.com/launchdarkly/python-ai-sdk" "Bug Tracker" = "https://github.com/launchdarkly/python-ai-sdk/issues" [project.optional-dependencies] -openai = ["langchain-openai>=1.3"] anthropic = ["langchain-anthropic>=1.4"] aws = ["langchain-aws>=1.7"] diff --git a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py index 9d401e0d..0ecaca1b 100644 --- a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py +++ b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py @@ -657,13 +657,7 @@ def _make_default_chat_model(config: AiConfigRep, importlib: Any) -> Any: renames=_CHAT_BEDROCK_CONVERSE_RENAMES, ) ) - try: - lc_openai = importlib.import_module("langchain_openai") - except ImportError as exc: - raise ImportError( - "Using OpenAI models requires langchain-openai. " - 'Install it with: pip install "launchdarkly-ai-langchain-messages[openai]"' - ) from exc + lc_openai = importlib.import_module("langchain_openai") return lc_openai.ChatOpenAI( **_model_constructor_kwargs( config, diff --git a/scripts/check_packaging.py b/scripts/check_packaging.py index b392df1c..251aa3e4 100644 --- a/scripts/check_packaging.py +++ b/scripts/check_packaging.py @@ -52,7 +52,6 @@ ("launchdarkly-ai-python", "otel"): _OTEL, ("launchdarkly-ai-langchain-agents", "anthropic"): ["langchain_anthropic"], ("launchdarkly-ai-langchain-agents", "aws"): ["langchain_aws"], - ("launchdarkly-ai-langchain-messages", "openai"): ["langchain_openai"], ("launchdarkly-ai-langchain-messages", "anthropic"): ["langchain_anthropic"], ("launchdarkly-ai-langchain-messages", "aws"): ["langchain_aws"], } @@ -79,13 +78,31 @@ # Prints which installed distributions provide each module, so the caller can compare # them with what the package declares. ``null`` means the module is not installed. +# A dotted name resolves to the distribution that owns its file, so a namespace package +# (``opentelemetry.sdk``) maps to ``opentelemetry-sdk``, not to every ``opentelemetry-*``. PROVIDERS = """ import importlib.metadata, importlib.util, json, sys -dists = importlib.metadata.packages_distributions() -print(json.dumps({ - name: dists.get(name, []) if importlib.util.find_spec(name) else None - for name in sys.argv[1:] -})) +owners = {} +for dist in importlib.metadata.distributions(): + for file in dist.files or []: + owners[str(dist.locate_file(file).resolve())] = dist.metadata["Name"] +top_level = importlib.metadata.packages_distributions() + +def providers(name): + try: + spec = importlib.util.find_spec(name) + except ImportError: + return None + if spec is None: + return None + if spec.origin and spec.has_location: + from pathlib import Path + owner = owners.get(str(Path(spec.origin).resolve())) + if owner: + return [owner] + return top_level.get(name.split(".")[0], []) + +print(json.dumps({name: providers(name) for name in sys.argv[1:]})) """ @@ -113,7 +130,7 @@ class Package: public: list[str] """Modules whose ``__all__`` is the public contract.""" imports: list[str] - """Top-level modules the source imports unconditionally.""" + """Modules, by full dotted name, that the source imports unconditionally.""" dependencies: list[str] = field(default_factory=list) optional: dict[str, list[str]] = field(default_factory=dict) """``[project.optional-dependencies]``: extra name to its requirements.""" @@ -200,7 +217,7 @@ def visit_If(self, node: ast.If) -> None: def _add(self, module: str) -> None: if not self._optional: - self.found.add(module.split(".")[0]) + self.found.add(module) def visit_Import(self, node: ast.Import) -> None: for alias in node.names: @@ -229,7 +246,8 @@ def source_imports(src: Path) -> list[str]: visitor = _Imports() for path in src.rglob("*.py"): visitor.visit(ast.parse(path.read_text(), str(path))) - return sorted(visitor.found - set(sys.stdlib_module_names) - {"__future__"}) + skip = set(sys.stdlib_module_names) | {"__future__"} + return sorted(m for m in visitor.found if m.split(".")[0] not in skip) def packages() -> list[Package]: diff --git a/tests/test_check_packaging.py b/tests/test_check_packaging.py index 53465005..32e20d0d 100644 --- a/tests/test_check_packaging.py +++ b/tests/test_check_packaging.py @@ -1,4 +1,4 @@ -"""Tests the import scan in scripts/check_packaging.py (TESTING.md §5.3). +"""Tests the import scan and run planning in scripts/check_packaging.py. The install half needs built wheels and PyPI, so CI runs it as ``make check-packaging``. """ @@ -27,7 +27,7 @@ def factory(): import importlib return importlib.import_module("pkg_c.models") """ - assert _scan(tmp_path, source) == ["pkg_a", "pkg_b", "pkg_c"] + assert _scan(tmp_path, source) == ["pkg_a", "pkg_b.sub", "pkg_c.models"] def test_an_import_guarded_by_import_error_is_optional(tmp_path: Path) -> None: @@ -115,6 +115,18 @@ def factory(): ] +def test_a_namespace_package_needs_its_own_distribution_declared() -> None: + # opentelemetry-api is declared; opentelemetry.sdk comes from opentelemetry-sdk. + declared = {"launchdarkly-ai-example", "opentelemetry-api"} + providers: dict[str, list[str] | None] = { + "opentelemetry.trace": ["opentelemetry-api"], + "opentelemetry.sdk.trace": ["opentelemetry-sdk"], + } + assert check_packaging.undeclared_imports(providers, declared) == [ + "opentelemetry.sdk.trace (from opentelemetry-sdk, not declared)" + ] + + def test_declared_and_missing_imports() -> None: declared = {"launchdarkly-ai-example", "typing-extensions"} providers: dict[str, list[str] | None] = { diff --git a/uv.lock b/uv.lock index 4e831b3d..f3053e7f 100644 --- a/uv.lock +++ b/uv.lock @@ -1364,6 +1364,7 @@ dependencies = [ { name = "langchain-core" }, { name = "langchain-openai" }, { name = "langgraph" }, + { name = "langgraph-prebuilt" }, { name = "launchdarkly-ai-server" }, { name = "opentelemetry-api" }, ] @@ -1383,6 +1384,7 @@ requires-dist = [ { name = "langchain-core", specifier = ">=1.6" }, { name = "langchain-openai", specifier = ">=1.3" }, { name = "langgraph", specifier = ">=1.2" }, + { name = "langgraph-prebuilt", specifier = ">=1.1" }, { name = "launchdarkly-ai-server", editable = "packages/client" }, { name = "opentelemetry-api", specifier = ">=1.25" }, ] @@ -1394,6 +1396,7 @@ version = "0.3.0" source = { editable = "packages/langchain-messages" } dependencies = [ { name = "langchain-core" }, + { name = "langchain-openai" }, { name = "launchdarkly-ai-server" }, { name = "opentelemetry-api" }, ] @@ -1405,20 +1408,17 @@ anthropic = [ aws = [ { name = "langchain-aws" }, ] -openai = [ - { name = "langchain-openai" }, -] [package.metadata] requires-dist = [ { name = "langchain-anthropic", marker = "extra == 'anthropic'", specifier = ">=1.4" }, { name = "langchain-aws", marker = "extra == 'aws'", specifier = ">=1.7" }, { name = "langchain-core", specifier = ">=1.6" }, - { name = "langchain-openai", marker = "extra == 'openai'", specifier = ">=1.3" }, + { name = "langchain-openai", specifier = ">=1.3" }, { name = "launchdarkly-ai-server", editable = "packages/client" }, { name = "opentelemetry-api", specifier = ">=1.25" }, ] -provides-extras = ["openai", "anthropic", "aws"] +provides-extras = ["anthropic", "aws"] [[package]] name = "launchdarkly-ai-litellm-agents" From bdd635bd454505f373be26c056756f3f87822ef3 Mon Sep 17 00:00:00 2001 From: Jeff Dupont Date: Thu, 29 Oct 2026 08:49:13 -0700 Subject: [PATCH 9/9] docs(AIC-3598): install commands name the [ld] extra launchdarkly-server-sdk is now an extra, so an install without it succeeds and then init_client() raises. The handler and barrel READMEs and the client telemetry install now match the root README and the other handlers (Bugbot). Co-Authored-By: Claude Opus 5.5 --- packages/ai/README.md | 2 +- packages/claude-agents/README.md | 2 +- packages/claude-messages/README.md | 2 +- packages/client/README.md | 2 +- packages/langchain-agents/README.md | 2 +- packages/langchain-messages/README.md | 2 +- packages/openai-agents/README.md | 2 +- packages/openai-messages/README.md | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/ai/README.md b/packages/ai/README.md index edb6413a..b2d8a1c6 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -5,7 +5,7 @@ Convenience barrel package for the LaunchDarkly AI Python SDK. Re-exports the co ## Installation ```bash -pip install launchdarkly-ai-python launchdarkly-ai-openai-messages +pip install "launchdarkly-ai-python[ld]" launchdarkly-ai-openai-messages ``` To enable trace export to the LaunchDarkly Observability dashboard, install the `otel` extras group: diff --git a/packages/claude-agents/README.md b/packages/claude-agents/README.md index 73737f8b..4de46a28 100644 --- a/packages/claude-agents/README.md +++ b/packages/claude-agents/README.md @@ -7,7 +7,7 @@ Anthropic Claude handler for `launchdarkly-ai-server` using the **Claude Agent S ## Installation ```bash -pip install launchdarkly-ai-server launchdarkly-ai-claude-agents +pip install "launchdarkly-ai-server[ld]" launchdarkly-ai-claude-agents ``` Set `ANTHROPIC_API_KEY` in your environment (the Anthropic SDK reads it automatically). diff --git a/packages/claude-messages/README.md b/packages/claude-messages/README.md index fd91126f..6e112a1b 100644 --- a/packages/claude-messages/README.md +++ b/packages/claude-messages/README.md @@ -7,7 +7,7 @@ Anthropic Claude handler for `launchdarkly-ai-server` using the **Anthropic Mess ## Installation ```bash -pip install launchdarkly-ai-server launchdarkly-ai-claude-messages +pip install "launchdarkly-ai-server[ld]" launchdarkly-ai-claude-messages ``` Set `ANTHROPIC_API_KEY` in your environment (the Anthropic SDK reads it automatically). diff --git a/packages/client/README.md b/packages/client/README.md index 52de15c8..040c7b63 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -23,7 +23,7 @@ The SDK works fully without the OpenTelemetry packages — feature flags evaluat To export traces to the LaunchDarkly Observability dashboard (or any OTLP-compatible backend), install the `otel` extras group: ```bash -pip install "launchdarkly-ai-server[otel]" +pip install "launchdarkly-ai-server[ld,otel]" ``` No code changes are required — `init_client()` detects the packages at runtime and sets up the tracer provider automatically. diff --git a/packages/langchain-agents/README.md b/packages/langchain-agents/README.md index 01fa9358..27fbfc8c 100644 --- a/packages/langchain-agents/README.md +++ b/packages/langchain-agents/README.md @@ -7,7 +7,7 @@ LangChain handler for `launchdarkly-ai-server` using **LangGraph's `StateGraph`* ## Installation ```bash -pip install launchdarkly-ai-server launchdarkly-ai-langchain-agents +pip install "launchdarkly-ai-server[ld]" launchdarkly-ai-langchain-agents ``` The default model is `ChatOpenAI`, so set `OPENAI_API_KEY` unless you pass a custom `BaseChatModel`. diff --git a/packages/langchain-messages/README.md b/packages/langchain-messages/README.md index fc8d5c8f..3a150ca7 100644 --- a/packages/langchain-messages/README.md +++ b/packages/langchain-messages/README.md @@ -7,7 +7,7 @@ LangChain handler for `launchdarkly-ai-server` using **LangChain chat models** ( ## Installation ```bash -pip install launchdarkly-ai-server launchdarkly-ai-langchain-messages +pip install "launchdarkly-ai-server[ld]" launchdarkly-ai-langchain-messages ``` The default model is `ChatOpenAI`, so set `OPENAI_API_KEY` unless you pass a custom `BaseChatModel`. diff --git a/packages/openai-agents/README.md b/packages/openai-agents/README.md index bf7e4ce6..1e606f39 100644 --- a/packages/openai-agents/README.md +++ b/packages/openai-agents/README.md @@ -7,7 +7,7 @@ OpenAI handler for `launchdarkly-ai-server` using the **OpenAI Agents SDK** (`op ## Installation ```bash -pip install launchdarkly-ai-server launchdarkly-ai-openai-agents +pip install "launchdarkly-ai-server[ld]" launchdarkly-ai-openai-agents ``` Set `OPENAI_API_KEY` in your environment (the OpenAI SDK reads it automatically). diff --git a/packages/openai-messages/README.md b/packages/openai-messages/README.md index 3ebd78c5..40b2c470 100644 --- a/packages/openai-messages/README.md +++ b/packages/openai-messages/README.md @@ -7,7 +7,7 @@ OpenAI handler for `launchdarkly-ai-server` using the **OpenAI Responses API** ( ## Installation ```bash -pip install launchdarkly-ai-server launchdarkly-ai-openai-messages +pip install "launchdarkly-ai-server[ld]" launchdarkly-ai-openai-messages ``` Set `OPENAI_API_KEY` in your environment (the OpenAI SDK reads it automatically).