diff --git a/packages/ai-providers/server-ai-langchain/pyproject.toml b/packages/ai-providers/server-ai-langchain/pyproject.toml index a8fe2ef..f2469ac 100644 --- a/packages/ai-providers/server-ai-langchain/pyproject.toml +++ b/packages/ai-providers/server-ai-langchain/pyproject.toml @@ -24,6 +24,9 @@ dependencies = [ "langchain>=1.0.0", ] +[project.optional-dependencies] +graph = ["langgraph>=0.1.0"] + [project.urls] Homepage = "https://docs.launchdarkly.com/sdk/ai/python" Repository = "https://github.com/launchdarkly/python-server-sdk-ai" @@ -36,6 +39,7 @@ dev = [ "mypy==1.18.2", "pycodestyle>=2.11.0", "isort>=5.12.0", + "langgraph>=0.1.0", ] [build-system] diff --git a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_agent_runner.py b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_agent_runner.py index b0a1c85..bb63771 100644 --- a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_agent_runner.py +++ b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_agent_runner.py @@ -6,7 +6,7 @@ from ldai.providers import AgentResult, AgentRunner from ldai.providers.types import LDAIMetrics -from ldai_langchain.langchain_helper import sum_token_usage_from_messages +from ldai_langchain.langchain_helper import extract_last_message_content, sum_token_usage_from_messages class LangChainAgentRunner(AgentRunner): @@ -37,11 +37,7 @@ async def run(self, input: Any) -> AgentResult: "messages": [{"role": "user", "content": str(input)}] }) messages = result.get("messages", []) - output = "" - if messages: - last = messages[-1] - if hasattr(last, 'content') and isinstance(last.content, str): - output = last.content + output = extract_last_message_content(messages) return AgentResult( output=output, raw=result, diff --git a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_helper.py b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_helper.py index 677d243..b1b18c8 100644 --- a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_helper.py +++ b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_helper.py @@ -1,7 +1,7 @@ -from typing import Any, Dict, List, Optional, Union +from typing import Any, List, Optional from langchain_core.language_models.chat_models import BaseChatModel -from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage from ldai import LDMessage, log from ldai.models import AIConfigKind from ldai.providers import ToolRegistry @@ -51,18 +51,12 @@ def convert_messages_to_langchain( return result -def create_langchain_model(ai_config: AIConfigKind, tool_registry: Optional[ToolRegistry] = None) -> BaseChatModel: +def create_langchain_model(ai_config: AIConfigKind) -> BaseChatModel: """ Create a LangChain BaseChatModel from a LaunchDarkly AI configuration. - If the config includes tool definitions and a tool_registry is provided, tools found - in the registry are bound to the model. Tools not found in the registry are skipped - with a warning. Built-in provider tools (e.g. code_interpreter) are not supported - via LangChain's bind_tools abstraction and are skipped with a warning. - :param ai_config: The LaunchDarkly AI configuration - :param tool_registry: Optional registry mapping tool names to callable implementations - :return: A configured LangChain BaseChatModel, with tools bound if applicable + :return: A configured LangChain BaseChatModel """ from langchain.chat_models import init_chat_model @@ -73,7 +67,7 @@ def create_langchain_model(ai_config: AIConfigKind, tool_registry: Optional[Tool model_name = model_dict.get('name', '') provider = provider_dict.get('name', '') parameters = dict(model_dict.get('parameters') or {}) - tool_definitions = parameters.pop('tools', []) or [] + parameters.pop('tools', None) mapped_provider = map_provider(provider) # Bedrock requires the foundation provider (e.g. Bedrock:Anthropic) passed in @@ -81,104 +75,43 @@ def create_langchain_model(ai_config: AIConfigKind, tool_registry: Optional[Tool if mapped_provider == 'bedrock_converse' and 'provider' not in parameters: parameters['provider'] = provider.removeprefix('bedrock:') - model = init_chat_model( + return init_chat_model( model_name, model_provider=mapped_provider, **parameters, ) - if tool_definitions and tool_registry is not None: - bindable = _resolve_tools_for_langchain(tool_definitions, tool_registry) - if bindable: - model = model.bind_tools(bindable) - - return model - - -def _iter_valid_tools( - tool_definitions: List[Dict[str, Any]], - tool_registry: ToolRegistry, -) -> List[tuple]: - """ - Filter LD tool definitions against a registry, returning (name, td) pairs for each - valid function tool that has a callable implementation. Built-in provider tools and - tools missing from the registry are skipped with a warning. - """ - valid = [] - for td in tool_definitions: - if not isinstance(td, dict): - continue - - tool_type = td.get('type') - if tool_type and tool_type != 'function': - log.warning( - f"Built-in tool '{tool_type}' is not reliably supported via LangChain and will be skipped. " - "Use a provider-specific runner to use built-in provider tools." - ) - continue - - name = td.get('name') - if not name: - continue - - if name not in tool_registry: - log.warning(f"Tool '{name}' is defined in the AI config but was not found in the tool registry; skipping.") - continue - - valid.append((name, td)) - - return valid - -def _resolve_tools_for_langchain( - tool_definitions: List[Dict[str, Any]], - tool_registry: ToolRegistry, -) -> List[Dict[str, Any]]: - """ - Match LD tool definitions against a registry, returning function-calling tool dicts - for tools that have a callable implementation. Built-in provider tools and tools - missing from the registry are skipped with a warning. - """ - return [ - { - 'type': 'function', - 'function': { - 'name': name, - 'description': td.get('description', ''), - 'parameters': td.get('parameters', {'type': 'object', 'properties': {}}), - }, - } - for name, td in _iter_valid_tools(tool_definitions, tool_registry) - ] - - -def build_structured_tools(ai_config: AIConfigKind, tool_registry: ToolRegistry) -> List[Any]: +def build_tools(ai_config: AIConfigKind, tool_registry: ToolRegistry) -> List[Any]: """ - Build a list of LangChain StructuredTool instances from LD tool definitions and a registry. + Return callables from the registry for each tool defined in the AI config. - Tools found in the registry are wrapped as StructuredTool with the name and description - from the LD config. Built-in provider tools and tools missing from the registry are - skipped with a warning. + Tools not found in the registry are skipped with a warning. The returned + callables can be passed directly to bind_tools or langchain.agents.create_agent. + Functions should have type-annotated parameters so LangChain can infer the schema. :param ai_config: The LaunchDarkly AI configuration :param tool_registry: Registry mapping tool names to callable implementations - :return: List of StructuredTool instances ready to pass to langchain.agents.create_agent + :return: List of callables ready to pass to bind_tools or create_agent """ - from langchain_core.tools import StructuredTool - config_dict = ai_config.to_dict() model_dict = config_dict.get('model') or {} parameters = dict(model_dict.get('parameters') or {}) tool_definitions = parameters.pop('tools', []) or [] - return [ - StructuredTool.from_function( - func=tool_registry[name], - name=name, - description=td.get('description', ''), - ) - for name, td in _iter_valid_tools(tool_definitions, tool_registry) - ] + tools = [] + for td in tool_definitions: + if not isinstance(td, dict): + continue + name = td.get('name') + if not name: + continue + fn = tool_registry.get(name) + if fn is None: + log.warning(f"Tool '{name}' is defined in the AI config but was not found in the tool registry; skipping.") + continue + tools.append(fn) + return tools def get_ai_usage_from_response(response: Any) -> Optional[TokenUsage]: @@ -234,6 +167,20 @@ def get_tool_calls_from_response(response: Any) -> List[str]: return names +def extract_last_message_content(messages: List[Any]) -> str: + """ + Extract the string content of the last message in a list. + + :param messages: List of LangChain message objects + :return: String content of the last message, or empty string if none + """ + if messages: + last = messages[-1] + if hasattr(last, 'content'): + return str(last.content) + return '' + + def sum_token_usage_from_messages(messages: List[Any]) -> Optional[TokenUsage]: """ Sum token usage across LangChain messages using get_ai_usage_from_response per message. diff --git a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_runner_factory.py b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_runner_factory.py index 29ab68b..6bea7ed 100644 --- a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_runner_factory.py +++ b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_runner_factory.py @@ -5,7 +5,7 @@ from ldai_langchain.langchain_agent_runner import LangChainAgentRunner from ldai_langchain.langchain_helper import ( - build_structured_tools, + build_tools, create_langchain_model, ) from ldai_langchain.langchain_model_runner import LangChainModelRunner @@ -48,7 +48,7 @@ def create_agent(self, config: Any, tools: Optional[ToolRegistry] = None) -> Lan from langchain.agents import create_agent as lc_create_agent instructions = (config.instructions or '') if hasattr(config, 'instructions') else '' llm = create_langchain_model(config) - lc_tools = build_structured_tools(config, tools or {}) + lc_tools = build_tools(config, tools or {}) agent = lc_create_agent( llm, diff --git a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langgraph_agent_graph_runner.py b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langgraph_agent_graph_runner.py index c0c0b5c..b86ad6e 100644 --- a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langgraph_agent_graph_runner.py +++ b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langgraph_agent_graph_runner.py @@ -10,7 +10,9 @@ from ldai.providers.types import LDAIMetrics from ldai_langchain.langchain_helper import ( + build_tools, create_langchain_model, + extract_last_message_content, get_ai_metrics_from_response, get_ai_usage_from_response, get_tool_calls_from_response, @@ -73,12 +75,7 @@ def handle_traversal(node: AgentGraphNode, ctx: dict) -> None: model = None if node_config.model: lc_model = create_langchain_model(node_config) - tool_defs = node_config.model.get_parameter('tools') or [] - tool_fns = [ - tools_ref[t.get('name', '')] - for t in tool_defs - if t.get('name', '') in tools_ref - ] + tool_fns = build_tools(node_config, tools_ref) model = lc_model.bind_tools(tool_fns) if tool_fns else lc_model def invoke(state: WorkflowState) -> WorkflowState: @@ -124,12 +121,8 @@ def invoke(state: WorkflowState) -> WorkflowState: ) duration = (time.perf_counter_ns() - start_ns) // 1_000_000 - output = '' messages = result.get('messages', []) - if messages: - last = messages[-1] - if hasattr(last, 'content'): - output = str(last.content) + output = extract_last_message_content(messages) if tracker: tracker.track_path(exec_path) diff --git a/packages/ai-providers/server-ai-langchain/tests/test_langchain_provider.py b/packages/ai-providers/server-ai-langchain/tests/test_langchain_provider.py index 52cca65..bad5640 100644 --- a/packages/ai-providers/server-ai-langchain/tests/test_langchain_provider.py +++ b/packages/ai-providers/server-ai-langchain/tests/test_langchain_provider.py @@ -422,7 +422,7 @@ def test_creates_agent_runner_with_instructions_and_tool_definitions(self): mock_agent = MagicMock() with patch('ldai_langchain.langchain_runner_factory.create_langchain_model') as mock_create, \ - patch('ldai_langchain.langchain_runner_factory.build_structured_tools') as mock_tools, \ + patch('ldai_langchain.langchain_runner_factory.build_tools') as mock_tools, \ patch('langchain.agents.create_agent', return_value=mock_agent): mock_create.return_value = MagicMock() mock_tools.return_value = [MagicMock()] @@ -447,7 +447,7 @@ def test_creates_agent_runner_with_no_tools(self): mock_agent = MagicMock() with patch('ldai_langchain.langchain_runner_factory.create_langchain_model') as mock_create, \ - patch('ldai_langchain.langchain_runner_factory.build_structured_tools', return_value=[]), \ + patch('ldai_langchain.langchain_runner_factory.build_tools', return_value=[]), \ patch('langchain.agents.create_agent', return_value=mock_agent): mock_create.return_value = MagicMock() diff --git a/packages/ai-providers/server-ai-openai/pyproject.toml b/packages/ai-providers/server-ai-openai/pyproject.toml index eec8642..0f7f78c 100644 --- a/packages/ai-providers/server-ai-openai/pyproject.toml +++ b/packages/ai-providers/server-ai-openai/pyproject.toml @@ -23,6 +23,9 @@ dependencies = [ "openai>=1.0.0", ] +[project.optional-dependencies] +agents = ["openai-agents>=0.0.1"] + [project.urls] Homepage = "https://docs.launchdarkly.com/sdk/ai/python" Repository = "https://github.com/launchdarkly/python-server-sdk-ai" @@ -35,6 +38,7 @@ dev = [ "mypy==1.18.2", "pycodestyle>=2.11.0", "isort>=5.12.0", + "openai-agents>=0.0.1", ] [build-system] diff --git a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_graph_runner.py b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_graph_runner.py index a0840c2..fbddd2d 100644 --- a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_graph_runner.py +++ b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_graph_runner.py @@ -1,5 +1,6 @@ """OpenAI agent graph runner for LaunchDarkly AI SDK.""" +import json import time from typing import Any, List, Optional @@ -11,7 +12,9 @@ from ldai_openai.openai_helper import ( NATIVE_OPENAI_TOOLS, + extract_usage_from_request_entry, get_ai_usage_from_response, + get_tool_calls_from_run_items, ) @@ -39,7 +42,7 @@ def __init__(self, graph: AgentGraphDefinition, tools: ToolRegistry): Initialize the runner. :param graph: The AgentGraphDefinition to execute - :param tools: Registry mapping OpenAI-formatted tool names to callables + :param tools: Registry mapping tool names to callables """ self._graph = graph self._tools = tools @@ -69,6 +72,7 @@ async def run(self, input: Any) -> AgentGraphResult: root_agent = self._build_agents(path, state) result = await Runner.run(root_agent, str(input)) self._flush_final_segment(state, tracker, result) + self._track_tool_calls(result, tracker) duration = (time.perf_counter_ns() - start_ns) // 1_000_000 @@ -103,94 +107,6 @@ async def run(self, input: Any) -> AgentGraphResult: metrics=LDAIMetrics(success=False), ) - def _flush_final_segment( - self, - state: _RunState, - tracker: Any, - result: Any, - ) -> None: - """Record duration/tokens for the last active agent (no handoff after it).""" - if not state.last_node_key: - return - node = self._graph.get_node(state.last_node_key) - if node is None: - return - config_tracker = node.get_config().tracker - if config_tracker is None: - return - - now_ns = time.perf_counter_ns() - duration_ms = (now_ns - state.last_handoff_ns) // 1_000_000 - - usage: Optional[TokenUsage] = None - try: - usage_entry = result.context_wrapper.usage.request_usage_entries[-1] - usage = TokenUsage( - total=usage_entry.total_tokens, - input=usage_entry.input_tokens, - output=usage_entry.output_tokens, - ) - except Exception: - pass - - gk = tracker.graph_key if tracker is not None else None - if usage is not None: - config_tracker.track_tokens(usage, graph_key=gk) - config_tracker.track_duration(int(duration_ms), graph_key=gk) - config_tracker.track_success(graph_key=gk) - - def _handle_handoff( - self, - run_ctx: Any, - src: str, - tgt: str, - path: List[str], - tracker: Any, - config_tracker: Any, - state: _RunState, - ) -> None: - path.append(tgt) - state.last_node_key = tgt - if tracker: - tracker.track_handoff_success(src, tgt) - - usage: Optional[TokenUsage] = None - now_ns = time.perf_counter_ns() - duration_ms = (now_ns - state.last_handoff_ns) // 1_000_000 - state.last_handoff_ns = now_ns - try: - usage_entry = run_ctx.usage.request_usage_entries[-1] - usage = TokenUsage( - total=usage_entry.total_tokens, - input=usage_entry.input_tokens, - output=usage_entry.output_tokens, - ) - except Exception: - pass - - gk = tracker.graph_key if tracker is not None else None - if config_tracker is not None: - if usage is not None: - config_tracker.track_tokens(usage, graph_key=gk) - if duration_ms is not None: - config_tracker.track_duration(int(duration_ms), graph_key=gk) - config_tracker.track_success(graph_key=gk) - - def _make_on_handoff( - self, - src: str, - tgt: str, - path: List[str], - tracker: Any, - config_tracker: Any, - state: _RunState, - ): - def on_handoff(run_ctx: Any) -> None: - self._handle_handoff( - run_ctx, src, tgt, path, tracker, config_tracker, state - ) - return on_handoff - def _build_agents(self, path: List[str], state: _RunState) -> Any: """ Build the agent tree from the graph definition via reverse_traverse. @@ -207,12 +123,10 @@ def _build_agents(self, path: List[str], state: _RunState) -> Any: Agent, FunctionTool, Handoff, - RunContextWrapper, Tool, handoff, ) from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX - from agents.tool_context import ToolContext except ImportError as exc: raise ImportError( "openai-agents is required for OpenAIAgentGraphRunner. " @@ -269,22 +183,22 @@ def _make_tool( description: str, params_schema: dict, ) -> FunctionTool: - def wrapped(tool_ctx: ToolContext, tool_args: str) -> Any: - import json + async def wrapped(tool_ctx: Any, tool_args: str) -> str: try: - args = json.loads(tool_args) + args = json.loads(tool_args) if tool_args else {} except Exception: args = {} - path.append(name) - if config_tracker is not None: - config_tracker.track_tool_call( - name, - graph_key=tracker.graph_key if tracker is not None else None, - ) - return fn(**args) + try: + res = fn(**args) + if hasattr(res, "__await__"): + res = await res + return str(res) + except Exception as e: + log.warning(f"Tool '{name}' execution failed: {e}") + return f"Tool execution failed: {e}" return FunctionTool( - name=f'tool_{name}', + name=name, description=description, params_json_schema=params_schema, on_invoke_tool=wrapped, @@ -301,9 +215,104 @@ def wrapped(tool_ctx: ToolContext, tool_args: str) -> Any: return Agent( name=node_config.key, + model=model.name, instructions=f'{RECOMMENDED_PROMPT_PREFIX} {node_config.instructions or ""}', handoffs=list(agent_handoffs), tools=list(agent_tools), ) return self._graph.reverse_traverse(fn=build_node) + + def _make_on_handoff( + self, + src: str, + tgt: str, + path: List[str], + tracker: Any, + config_tracker: Any, + state: _RunState, + ): + def on_handoff(run_ctx: Any) -> None: + self._handle_handoff( + run_ctx, src, tgt, path, tracker, config_tracker, state + ) + return on_handoff + + def _handle_handoff( + self, + run_ctx: Any, + src: str, + tgt: str, + path: List[str], + tracker: Any, + config_tracker: Any, + state: _RunState, + ) -> None: + path.append(tgt) + state.last_node_key = tgt + if tracker: + tracker.track_handoff_success(src, tgt) + + now_ns = time.perf_counter_ns() + duration_ms = (now_ns - state.last_handoff_ns) // 1_000_000 + state.last_handoff_ns = now_ns + + usage: Optional[TokenUsage] = None + try: + usage = extract_usage_from_request_entry( + run_ctx.usage.request_usage_entries[-1] + ) + except Exception: + pass + + gk = tracker.graph_key if tracker is not None else None + if config_tracker is not None: + if usage is not None: + config_tracker.track_tokens(usage, graph_key=gk) + if duration_ms is not None: + config_tracker.track_duration(int(duration_ms), graph_key=gk) + config_tracker.track_success(graph_key=gk) + + def _flush_final_segment( + self, + state: _RunState, + tracker: Any, + result: Any, + ) -> None: + """Record duration/tokens for the last active agent (no handoff after it).""" + if not state.last_node_key: + return + node = self._graph.get_node(state.last_node_key) + if node is None: + return + config_tracker = node.get_config().tracker + if config_tracker is None: + return + + now_ns = time.perf_counter_ns() + duration_ms = (now_ns - state.last_handoff_ns) // 1_000_000 + + usage: Optional[TokenUsage] = None + try: + usage = extract_usage_from_request_entry( + result.context_wrapper.usage.request_usage_entries[-1] + ) + except Exception: + pass + + gk = tracker.graph_key if tracker is not None else None + if usage is not None: + config_tracker.track_tokens(usage, graph_key=gk) + config_tracker.track_duration(int(duration_ms), graph_key=gk) + config_tracker.track_success(graph_key=gk) + + def _track_tool_calls(self, result: Any, tracker: Any) -> None: + """Track all tool calls from the run result, attributed to the node that called them.""" + gk = tracker.graph_key if tracker is not None else None + for agent_name, tool_name in get_tool_calls_from_run_items(result.new_items): + node = self._graph.get_node(agent_name) + if node is None: + continue + config_tracker = node.get_config().tracker + if config_tracker is not None: + config_tracker.track_tool_call(tool_name, graph_key=gk) diff --git a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_runner.py b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_runner.py index 199bef4..7bd0889 100644 --- a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_runner.py +++ b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_runner.py @@ -93,53 +93,45 @@ def _build_agent_tools(self) -> List[Any]: if not isinstance(td, dict): continue name = td.get("name", "") - - # Native OpenAI tools run on OpenAI's infrastructure — no local fn required. - if name and name in NATIVE_OPENAI_TOOLS: - tools.append(NATIVE_OPENAI_TOOLS[name](td)) - continue - - tool_type = td.get("type") - if tool_type and tool_type != "function": - log.warning( - f"Built-in tool '{tool_type}' is not supported and will be skipped. " - "Use the OpenAIAgentGraphRunner for built-in provider tools." - ) - continue - if not name: continue tool_fn = self._tools.get(name) - if not tool_fn: - log.warning( - f"Tool '{name}' is defined in the AI config but was not found in " - "the tool registry; skipping." - ) + if tool_fn: + def _make_invoker(fn: Any, tool_name: str) -> Any: + async def on_invoke_tool(tool_ctx: ToolContext, args_json: str) -> str: + try: + args = json.loads(args_json) if args_json else {} + except Exception: + args = {} + try: + res = fn(**args) + if hasattr(res, "__await__"): + res = await res + return str(res) + except Exception as e: + log.warning(f"Tool '{tool_name}' execution failed: {e}") + return f"Tool execution failed: {e}" + return on_invoke_tool + + tools.append(FunctionTool( + name=name, + description=td.get("description", ""), + params_json_schema=td.get("parameters", {}), + on_invoke_tool=_make_invoker(tool_fn, name), + )) continue - def _make_invoker(fn: Any, tool_name: str) -> Any: - async def on_invoke_tool(tool_ctx: ToolContext, args_json: str) -> str: - try: - args = json.loads(args_json) if args_json else {} - except Exception: - args = {} - try: - res = fn(**args) - if hasattr(res, "__await__"): - res = await res - return str(res) - except Exception as e: - log.warning(f"Tool '{tool_name}' execution failed: {e}") - return f"Tool execution failed: {e}" - return on_invoke_tool - - tools.append(FunctionTool( - name=name, - description=td.get("description", ""), - params_json_schema=td.get("parameters", {}), - on_invoke_tool=_make_invoker(tool_fn, name), - )) + # No callable in registry — try native OpenAI tool (exact name match required). + native = NATIVE_OPENAI_TOOLS.get(name) + if native: + tools.append(native(td)) + continue + + log.warning( + f"Tool '{name}' is defined in the AI config but was not found in " + "the tool registry and is not a known native tool; skipping." + ) return tools def _build_model_settings(self) -> Any: diff --git a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_helper.py b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_helper.py index 6088e89..6e4ade1 100644 --- a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_helper.py +++ b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_helper.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Iterable, List, Optional, cast +from typing import Any, Dict, Iterable, List, Optional, Tuple, cast from ldai import LDMessage from ldai.providers.types import LDAIMetrics @@ -8,17 +8,9 @@ def _build_native_tool_map() -> Dict[str, Any]: try: - from agents import ( - CodeInterpreterTool, - FileSearchTool, - ImageGenerationTool, - WebSearchTool, - ) + from agents import WebSearchTool return { 'web_search_tool': lambda _: WebSearchTool(), - 'file_search_tool': lambda _: FileSearchTool(), - 'code_interpreter': lambda _: CodeInterpreterTool(), - 'image_generation': lambda _: ImageGenerationTool(), } except ImportError: return {} @@ -72,6 +64,23 @@ def get_ai_usage_from_response(response: Any) -> Optional[TokenUsage]: return None +def extract_usage_from_request_entry(entry: Any) -> Optional[TokenUsage]: + """ + Extract token usage from a single request_usage_entry in an openai-agents RunResult. + + :param entry: A request_usage_entry from context_wrapper.usage.request_usage_entries + :return: TokenUsage or None if extraction fails + """ + try: + return TokenUsage( + total=entry.total_tokens, + input=entry.input_tokens, + output=entry.output_tokens, + ) + except Exception: + return None + + def get_ai_metrics_from_response(response: Any) -> LDAIMetrics: """ Extract LaunchDarkly AI metrics from an OpenAI response. @@ -80,3 +89,45 @@ def get_ai_metrics_from_response(response: Any) -> LDAIMetrics: :return: LDAIMetrics with success status and token usage """ return LDAIMetrics(success=True, usage=get_ai_usage_from_response(response)) + + +# Native tool raw_item type names don't always match the LD config key convention. +_NATIVE_TOOL_TYPE_TO_CONFIG_KEY = { + 'web_search': 'web_search_tool', +} + + +def get_tool_calls_from_run_items(new_items: List[Any]) -> List[Tuple[str, str]]: + """ + Extract (agent_name, tool_name) pairs from RunResult.new_items. + + Covers both custom FunctionTools (tracked by their config key) and native + hosted tools (web search, file search, code interpreter, image generation). + + :param new_items: The new_items list from a RunResult + :return: List of (agent_name, tool_name) tuples + """ + try: + from agents.items import ToolCallItem + from openai.types.responses import ResponseFunctionToolCall + except ImportError: + return [] + + result = [] + for item in new_items: + if not isinstance(item, ToolCallItem): + continue + agent_name = getattr(item.agent, 'name', None) + if not agent_name: + continue + raw = item.raw_item + if isinstance(raw, ResponseFunctionToolCall): + tool_name = raw.name + else: + raw_type = getattr(raw, 'type', None) or (raw.get('type') if isinstance(raw, dict) else None) + if not raw_type: + continue + tool_name = _NATIVE_TOOL_TYPE_TO_CONFIG_KEY.get(raw_type, raw_type) + if tool_name: + result.append((agent_name, tool_name)) + return result diff --git a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_runner_factory.py b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_runner_factory.py index 644d12f..2816655 100644 --- a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_runner_factory.py +++ b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_runner_factory.py @@ -24,6 +24,17 @@ def __init__(self, client: Optional[AsyncOpenAI] = None): api_key=os.environ.get('OPENAI_API_KEY'), ) + def _extract_model_config(self, config: AIConfigKind) -> tuple: + """ + Extract model name and parameters from an AI config. + + :param config: The LaunchDarkly AI configuration + :return: Tuple of (model_name, parameters) + """ + config_dict = config.to_dict() + model_dict = config_dict.get('model') or {} + return model_dict.get('name', ''), model_dict.get('parameters') or {} + def create_model(self, config: AIConfigKind) -> OpenAIModelRunner: """ Create a configured OpenAIModelRunner for the given AI config. @@ -33,10 +44,7 @@ def create_model(self, config: AIConfigKind) -> OpenAIModelRunner: :param config: The LaunchDarkly AI configuration :return: OpenAIModelRunner ready to invoke the model """ - config_dict = config.to_dict() - model_dict = config_dict.get('model') or {} - model_name = model_dict.get('name', '') - parameters = model_dict.get('parameters') or {} + model_name, parameters = self._extract_model_config(config) return OpenAIModelRunner(self._client, model_name, parameters) def create_agent_graph(self, graph_def: Any, tools: ToolRegistry) -> Any: @@ -60,10 +68,8 @@ def create_agent(self, config: Any, tools: Optional[ToolRegistry] = None) -> 'Op """ from ldai_openai.openai_agent_runner import OpenAIAgentRunner - config_dict = config.to_dict() - model_dict = config_dict.get('model') or {} - model_name = model_dict.get('name', '') - parameters = dict(model_dict.get('parameters') or {}) + model_name, base_parameters = self._extract_model_config(config) + parameters = dict(base_parameters) tool_definitions = parameters.pop('tools', []) or [] instructions = (config.instructions or '') if hasattr(config, 'instructions') else '' diff --git a/packages/sdk/server-ai/src/ldai/client.py b/packages/sdk/server-ai/src/ldai/client.py index d77974e..cdc14c4 100644 --- a/packages/sdk/server-ai/src/ldai/client.py +++ b/packages/sdk/server-ai/src/ldai/client.py @@ -32,14 +32,15 @@ from ldai.tracker import AIGraphTracker, LDAIConfigTracker _TRACK_SDK_INFO = '$ld:ai:sdk:info' -_TRACK_USAGE_COMPLETION_CONFIG = '$ld:ai:usage:completion-config' -_TRACK_USAGE_CREATE_MODEL = '$ld:ai:usage:create-model' -_TRACK_USAGE_CREATE_AGENT = '$ld:ai:usage:create-agent' -_TRACK_USAGE_JUDGE_CONFIG = '$ld:ai:usage:judge-config' -_TRACK_USAGE_CREATE_JUDGE = '$ld:ai:usage:create-judge' + _TRACK_USAGE_AGENT_CONFIG = '$ld:ai:usage:agent-config' _TRACK_USAGE_AGENT_CONFIGS = '$ld:ai:usage:agent-configs' +_TRACK_USAGE_COMPLETION_CONFIG = '$ld:ai:usage:completion-config' +_TRACK_USAGE_CREATE_AGENT = '$ld:ai:usage:create-agent' _TRACK_USAGE_CREATE_AGENT_GRAPH = '$ld:ai:usage:create-agent-graph' +_TRACK_USAGE_CREATE_JUDGE = '$ld:ai:usage:create-judge' +_TRACK_USAGE_CREATE_MODEL = '$ld:ai:usage:create-model' +_TRACK_USAGE_JUDGE_CONFIG = '$ld:ai:usage:judge-config' _INIT_TRACK_CONTEXT = Context.builder('ld-internal-tracking').kind('ld_ai').anonymous(True).build() diff --git a/packages/sdk/server-ai/src/ldai/providers/runner_factory.py b/packages/sdk/server-ai/src/ldai/providers/runner_factory.py index 190704e..589896b 100644 --- a/packages/sdk/server-ai/src/ldai/providers/runner_factory.py +++ b/packages/sdk/server-ai/src/ldai/providers/runner_factory.py @@ -4,6 +4,8 @@ from ldai import log from ldai.models import AIConfigKind from ldai.providers.ai_provider import AIProvider +from ldai.providers.agent_graph_runner import AgentGraphRunner +from ldai.providers.agent_runner import AgentRunner from ldai.providers.model_runner import ModelRunner T = TypeVar('T') @@ -133,14 +135,14 @@ def create_agent( config: Any, tools: Any, default_ai_provider: Optional[str] = None, - ) -> Optional[Any]: + ) -> Optional[AgentRunner]: """ Create an agent executor for the given AI agent config and tool registry. :param config: LaunchDarkly AI agent config :param tools: Tool registry mapping tool names to callables :param default_ai_provider: Optional provider override - :return: AgentExecutor instance, or None + :return: AgentRunner instance, or None """ provider_name = config.provider.name.lower() if config.provider else None providers = RunnerFactory._get_providers_to_try(default_ai_provider, provider_name) @@ -151,14 +153,14 @@ def create_agent_graph( graph_def: Any, tools: Any, default_ai_provider: Optional[str] = None, - ) -> Optional[Any]: + ) -> Optional[AgentGraphRunner]: """ Create an agent graph executor for the given graph definition and tool registry. :param graph_def: AgentGraphDefinition instance :param tools: Tool registry mapping tool names to callables :param default_ai_provider: Optional provider override - :return: AgentGraphExecutor instance, or None + :return: AgentGraphRunner instance, or None """ provider_name = None if graph_def.root() and graph_def.root().get_config() and graph_def.root().get_config().provider: