dispatch_agents 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. agentservice/__init__.py +0 -0
  2. agentservice/py.typed +0 -0
  3. agentservice/v1/__init__.py +0 -0
  4. agentservice/v1/message_pb2.py +41 -0
  5. agentservice/v1/message_pb2.pyi +22 -0
  6. agentservice/v1/message_pb2_grpc.py +4 -0
  7. agentservice/v1/request_response_pb2.py +46 -0
  8. agentservice/v1/request_response_pb2.pyi +54 -0
  9. agentservice/v1/request_response_pb2_grpc.py +4 -0
  10. agentservice/v1/service_pb2.py +43 -0
  11. agentservice/v1/service_pb2.pyi +6 -0
  12. agentservice/v1/service_pb2_grpc.py +129 -0
  13. dispatch_agents/__init__.py +281 -0
  14. dispatch_agents/agent_service.py +135 -0
  15. dispatch_agents/config.py +490 -0
  16. dispatch_agents/contrib/__init__.py +1 -0
  17. dispatch_agents/contrib/claude/__init__.py +246 -0
  18. dispatch_agents/contrib/openai/__init__.py +167 -0
  19. dispatch_agents/events.py +986 -0
  20. dispatch_agents/grpc_server.py +565 -0
  21. dispatch_agents/instrument.py +217 -0
  22. dispatch_agents/integrations/__init__.py +1 -0
  23. dispatch_agents/integrations/github/README.md +9 -0
  24. dispatch_agents/integrations/github/__init__.py +4268 -0
  25. dispatch_agents/invocation.py +25 -0
  26. dispatch_agents/llm.py +1017 -0
  27. dispatch_agents/llm_langchain.py +394 -0
  28. dispatch_agents/logging_config.py +133 -0
  29. dispatch_agents/mcp.py +266 -0
  30. dispatch_agents/memory.py +264 -0
  31. dispatch_agents/models.py +748 -0
  32. dispatch_agents/proxy/__init__.py +6 -0
  33. dispatch_agents/proxy/server.py +1137 -0
  34. dispatch_agents/proxy/sse_utils.py +76 -0
  35. dispatch_agents/py.typed +0 -0
  36. dispatch_agents/resources.py +68 -0
  37. dispatch_agents/version.py +19 -0
  38. dispatch_agents-0.9.0.dist-info/METADATA +20 -0
  39. dispatch_agents-0.9.0.dist-info/RECORD +43 -0
  40. dispatch_agents-0.9.0.dist-info/WHEEL +4 -0
  41. dispatch_agents-0.9.0.dist-info/licenses/LICENSE +191 -0
  42. dispatch_agents-0.9.0.dist-info/licenses/LICENSE-3rdparty.csv +12 -0
  43. dispatch_agents-0.9.0.dist-info/licenses/NOTICE +5 -0
@@ -0,0 +1,394 @@
1
+ """LangChain adapter for Dispatch LLM Gateway.
2
+
3
+ Provides a LangChain-compatible ChatModel that uses the Dispatch LLM proxy
4
+ for automatic trace correlation and cost tracking.
5
+
6
+ Example:
7
+ from dispatch_agents.llm_langchain import ChatDispatch
8
+
9
+ # Use as a drop-in replacement for ChatOpenAI
10
+ llm = ChatDispatch(model="gpt-4o")
11
+
12
+ # Works with LangGraph
13
+ agent = create_react_agent(llm, tools=tools)
14
+
15
+ # Works with structured output
16
+ structured_llm = llm.with_structured_output(MySchema)
17
+ """
18
+
19
+ from collections.abc import Sequence
20
+ from typing import Any
21
+
22
+ from langchain_core.callbacks import (
23
+ AsyncCallbackManagerForLLMRun,
24
+ CallbackManagerForLLMRun,
25
+ )
26
+ from langchain_core.language_models.chat_models import BaseChatModel
27
+ from langchain_core.messages import (
28
+ AIMessage,
29
+ BaseMessage,
30
+ HumanMessage,
31
+ SystemMessage,
32
+ ToolMessage,
33
+ )
34
+ from langchain_core.outputs import ChatGeneration, ChatResult
35
+ from pydantic import Field
36
+
37
+ from .llm import LLMClient, LLMResponse
38
+
39
+
40
+ def _get_tool_schema(tool: Any) -> dict[str, Any]:
41
+ """Extract JSON schema from a tool's args_schema, handling both Pydantic models and dicts."""
42
+ if not hasattr(tool, "args_schema") or not tool.args_schema:
43
+ return {}
44
+
45
+ args_schema = tool.args_schema
46
+
47
+ # If it's already a dict, return it directly
48
+ if isinstance(args_schema, dict):
49
+ return args_schema
50
+
51
+ # If it's a Pydantic model class, call model_json_schema()
52
+ if hasattr(args_schema, "model_json_schema"):
53
+ return args_schema.model_json_schema()
54
+
55
+ # Fallback: try to convert to dict
56
+ return {}
57
+
58
+
59
+ def _convert_tools_to_openai_format(tools: Sequence[Any]) -> list[dict[str, Any]]:
60
+ """Convert various tool formats to OpenAI function calling format.
61
+
62
+ Handles:
63
+ - LangChain BaseTool objects
64
+ - MCP tools (with args_schema as dict)
65
+ - Raw dicts already in OpenAI format
66
+ - Callable functions with docstrings
67
+ """
68
+ formatted_tools = []
69
+ for tool in tools:
70
+ if hasattr(tool, "name") and hasattr(tool, "description"):
71
+ # LangChain BaseTool or MCP tool
72
+ tool_schema: dict[str, Any] = {
73
+ "type": "function",
74
+ "function": {
75
+ "name": tool.name,
76
+ "description": tool.description or "",
77
+ },
78
+ }
79
+ # Get parameters schema, handling both Pydantic models and dicts
80
+ params = _get_tool_schema(tool)
81
+ if params:
82
+ tool_schema["function"]["parameters"] = params
83
+ formatted_tools.append(tool_schema)
84
+ elif isinstance(tool, dict):
85
+ # Already in OpenAI format
86
+ formatted_tools.append(tool)
87
+ elif callable(tool):
88
+ # Function with docstring
89
+ import inspect
90
+
91
+ sig = inspect.signature(tool)
92
+ formatted_tools.append(
93
+ {
94
+ "type": "function",
95
+ "function": {
96
+ "name": tool.__name__,
97
+ "description": tool.__doc__ or "",
98
+ "parameters": {
99
+ "type": "object",
100
+ "properties": {
101
+ name: {"type": "string"} for name in sig.parameters
102
+ },
103
+ },
104
+ },
105
+ }
106
+ )
107
+ return formatted_tools
108
+
109
+
110
+ def _serialize_tool_arguments(args: Any) -> str:
111
+ """Serialize tool call arguments to a JSON string.
112
+
113
+ OpenAI-compatible APIs expect tool call arguments as a JSON string,
114
+ not a Python dict string representation.
115
+ """
116
+ import json
117
+
118
+ if isinstance(args, str):
119
+ return args
120
+ if isinstance(args, dict):
121
+ return json.dumps(args)
122
+ # Fallback: try to convert to dict then serialize
123
+ try:
124
+ return json.dumps(dict(args))
125
+ except (TypeError, ValueError):
126
+ return json.dumps({})
127
+
128
+
129
+ def _convert_message_to_dict(message: BaseMessage) -> dict[str, Any]:
130
+ """Convert a LangChain message to a dict for the LLM API."""
131
+ if isinstance(message, SystemMessage):
132
+ return {"role": "system", "content": message.content}
133
+ elif isinstance(message, HumanMessage):
134
+ return {"role": "user", "content": message.content}
135
+ elif isinstance(message, AIMessage):
136
+ msg_dict: dict[str, Any] = {"role": "assistant", "content": message.content}
137
+ # Include tool calls if present
138
+ if message.tool_calls:
139
+ msg_dict["tool_calls"] = [
140
+ {
141
+ "id": tc["id"],
142
+ "type": "function",
143
+ "function": {
144
+ "name": tc["name"],
145
+ "arguments": _serialize_tool_arguments(tc["args"]),
146
+ },
147
+ }
148
+ for tc in message.tool_calls
149
+ ]
150
+ return msg_dict
151
+ elif isinstance(message, ToolMessage):
152
+ # tool_call_id is required by OpenAI-compatible APIs - use message id as fallback
153
+ tool_call_id = message.tool_call_id
154
+ if not tool_call_id:
155
+ # Fallback: use message id or generate one
156
+ tool_call_id = getattr(message, "id", None) or f"call_{id(message)}"
157
+ return {
158
+ "role": "tool",
159
+ "content": str(message.content) if message.content else "",
160
+ "tool_call_id": tool_call_id,
161
+ }
162
+ else:
163
+ # Fallback for other message types
164
+ return {"role": "user", "content": str(message.content)}
165
+
166
+
167
+ def _parse_tool_arguments(arguments: Any) -> dict[str, Any]:
168
+ """Parse tool call arguments to a dict.
169
+
170
+ OpenAI returns arguments as a JSON string, but LangChain expects a dict.
171
+ Handles various input formats gracefully.
172
+ """
173
+ import json
174
+
175
+ if isinstance(arguments, dict):
176
+ return arguments
177
+ if isinstance(arguments, str):
178
+ if not arguments or arguments.strip() == "":
179
+ return {}
180
+ try:
181
+ parsed = json.loads(arguments)
182
+ return parsed if isinstance(parsed, dict) else {}
183
+ except json.JSONDecodeError:
184
+ return {}
185
+ return {}
186
+
187
+
188
+ def _convert_response_to_message(response: LLMResponse) -> AIMessage:
189
+ """Convert an LLM response to a LangChain AIMessage."""
190
+ # Build tool_calls list if present
191
+ tool_calls = []
192
+ if response.tool_calls:
193
+ for tc in response.tool_calls:
194
+ tool_calls.append(
195
+ {
196
+ "id": tc.id,
197
+ "name": tc.function.name,
198
+ "args": _parse_tool_arguments(tc.function.arguments),
199
+ }
200
+ )
201
+
202
+ # Build response metadata
203
+ response_metadata = {
204
+ "llm_call_id": response.llm_call_id,
205
+ "model": response.model,
206
+ "provider": response.provider,
207
+ "finish_reason": response.finish_reason,
208
+ "input_tokens": response.input_tokens,
209
+ "output_tokens": response.output_tokens,
210
+ "cost_usd": response.cost_usd,
211
+ "latency_ms": response.latency_ms,
212
+ }
213
+ if response.variant_name:
214
+ response_metadata["variant_name"] = response.variant_name
215
+
216
+ # Build usage metadata
217
+ usage_metadata = {
218
+ "input_tokens": response.input_tokens,
219
+ "output_tokens": response.output_tokens,
220
+ "total_tokens": response.input_tokens + response.output_tokens,
221
+ }
222
+
223
+ return AIMessage(
224
+ content=response.content or "",
225
+ tool_calls=tool_calls if tool_calls else [],
226
+ response_metadata=response_metadata,
227
+ usage_metadata=usage_metadata,
228
+ )
229
+
230
+
231
+ class ChatDispatch(BaseChatModel):
232
+ """LangChain ChatModel that uses Dispatch LLM Gateway.
233
+
234
+ Provides automatic trace correlation and cost tracking for all LLM calls
235
+ when used within a Dispatch agent handler.
236
+
237
+ Example:
238
+ from dispatch_agents.llm_langchain import ChatDispatch
239
+
240
+ # Basic usage
241
+ llm = ChatDispatch(model="gpt-4o")
242
+ response = await llm.ainvoke([HumanMessage(content="Hello!")])
243
+
244
+ # With LangGraph ReAct agent
245
+ agent = create_react_agent(llm, tools=my_tools)
246
+
247
+ # With structured output
248
+ structured_llm = llm.with_structured_output(MyPydanticModel)
249
+ result = await structured_llm.ainvoke([...])
250
+
251
+ Args:
252
+ model: Model name (e.g., "gpt-4o", "claude-3-5-sonnet").
253
+ Uses org default if not specified.
254
+ provider: Provider name (e.g., "openai", "anthropic").
255
+ Uses org default if not specified.
256
+ temperature: Sampling temperature (0-2), default 1.0
257
+ max_tokens: Maximum tokens in response (optional)
258
+ """
259
+
260
+ model: str | None = Field(default=None, description="Model name to use")
261
+ provider: str | None = Field(default=None, description="Provider name")
262
+ temperature: float = Field(default=1.0, description="Sampling temperature")
263
+ max_tokens: int | None = Field(default=None, description="Max tokens in response")
264
+
265
+ _client: LLMClient | None = None
266
+
267
+ def __init__(self, **kwargs: Any) -> None:
268
+ super().__init__(**kwargs)
269
+ self._client = LLMClient()
270
+
271
+ @property
272
+ def _llm_type(self) -> str:
273
+ """Return the type of LLM."""
274
+ return "dispatch"
275
+
276
+ @property
277
+ def _identifying_params(self) -> dict[str, Any]:
278
+ """Return identifying parameters for caching."""
279
+ return {
280
+ "model": self.model,
281
+ "provider": self.provider,
282
+ "temperature": self.temperature,
283
+ "max_tokens": self.max_tokens,
284
+ }
285
+
286
+ def _generate(
287
+ self,
288
+ messages: list[BaseMessage],
289
+ stop: list[str] | None = None,
290
+ run_manager: CallbackManagerForLLMRun | None = None,
291
+ **kwargs: Any,
292
+ ) -> ChatResult:
293
+ """Synchronous generation - wraps async implementation."""
294
+ import asyncio
295
+
296
+ # Get or create event loop
297
+ try:
298
+ loop = asyncio.get_running_loop()
299
+ except RuntimeError:
300
+ loop = None
301
+
302
+ if loop and loop.is_running():
303
+ # We're in an async context, need to run in a new thread
304
+ import concurrent.futures
305
+
306
+ with concurrent.futures.ThreadPoolExecutor() as executor:
307
+ future = executor.submit(
308
+ asyncio.run,
309
+ self._agenerate(messages, stop, run_manager, **kwargs), # type: ignore[arg-type]
310
+ )
311
+ return future.result()
312
+ else:
313
+ # No running loop, we can use asyncio.run
314
+ return asyncio.run(
315
+ self._agenerate(messages, stop, run_manager, **kwargs) # type: ignore[arg-type]
316
+ )
317
+
318
+ async def _agenerate(
319
+ self,
320
+ messages: list[BaseMessage],
321
+ stop: list[str] | None = None,
322
+ run_manager: AsyncCallbackManagerForLLMRun | None = None,
323
+ **kwargs: Any,
324
+ ) -> ChatResult:
325
+ """Async generation using Dispatch LLM Gateway."""
326
+ if self._client is None:
327
+ self._client = LLMClient()
328
+
329
+ # Convert messages to API format
330
+ message_dicts = [_convert_message_to_dict(m) for m in messages]
331
+
332
+ # Extract tools from kwargs if provided (for function calling)
333
+ tools = kwargs.get("tools")
334
+ if tools:
335
+ # Convert tools to OpenAI format, handling various tool types
336
+ formatted_tools = _convert_tools_to_openai_format(tools)
337
+ tools = formatted_tools if formatted_tools else None
338
+
339
+ # Call the LLM Gateway
340
+ response = await self._client.inference(
341
+ messages=message_dicts,
342
+ model=self.model,
343
+ provider=self.provider,
344
+ tools=tools,
345
+ temperature=self.temperature,
346
+ max_tokens=self.max_tokens,
347
+ )
348
+
349
+ # Convert response to LangChain format
350
+ ai_message = _convert_response_to_message(response)
351
+ generation = ChatGeneration(message=ai_message)
352
+
353
+ return ChatResult(
354
+ generations=[generation],
355
+ llm_output={
356
+ "model": response.model,
357
+ "provider": response.provider,
358
+ "llm_call_id": response.llm_call_id,
359
+ "cost_usd": response.cost_usd,
360
+ "latency_ms": response.latency_ms,
361
+ },
362
+ )
363
+
364
+ def bind_tools(
365
+ self,
366
+ tools: Sequence[Any],
367
+ **kwargs: Any,
368
+ ) -> "ChatDispatch":
369
+ """Bind tools to this model for function calling.
370
+
371
+ Args:
372
+ tools: List of tools (LangChain tools, MCP tools, functions, or tool dicts)
373
+
374
+ Returns:
375
+ A new ChatDispatch instance with tools bound
376
+ """
377
+ # Convert tools to OpenAI format, handling various tool types
378
+ formatted_tools = _convert_tools_to_openai_format(tools)
379
+
380
+ # Return a new instance that will pass tools in kwargs.
381
+ # .bind() returns Runnable, not ChatDispatch, but callers expect ChatDispatch.
382
+ bound = (
383
+ self.__class__(
384
+ model=self.model,
385
+ provider=self.provider,
386
+ temperature=self.temperature,
387
+ max_tokens=self.max_tokens,
388
+ )
389
+ .configurable_fields(
390
+ # Store tools in config to be passed through
391
+ )
392
+ .bind(tools=formatted_tools, **kwargs)
393
+ )
394
+ return bound # type: ignore[return-value]
@@ -0,0 +1,133 @@
1
+ """Logging configuration for the Dispatch Agents SDK.
2
+
3
+ Controls the verbosity of SDK logging output via environment variables:
4
+ - DISPATCH_VERBOSE=1 or DISPATCH_VERBOSE=true: Enable verbose/debug logging
5
+ - DISPATCH_LOG_LEVEL=DEBUG/INFO/WARNING/ERROR: Set specific log level
6
+
7
+ By default, the SDK logs at INFO level, hiding debug messages like
8
+ re-subscription events. Enable verbose mode for debugging.
9
+ """
10
+
11
+ import logging
12
+ import os
13
+ import sys
14
+
15
+ # SDK logger namespace
16
+ SDK_LOGGER_NAME = "dispatch_agents"
17
+
18
+ # Check if we've already configured logging
19
+ _logging_configured = False
20
+
21
+
22
+ def _parse_bool_env(value: str | None) -> bool:
23
+ """Parse boolean from environment variable value."""
24
+ if value is None:
25
+ return False
26
+ return value.lower() in ("1", "true", "yes", "on")
27
+
28
+
29
+ def _get_log_level() -> int:
30
+ """Determine the log level from environment variables.
31
+
32
+ Priority:
33
+ 1. DISPATCH_LOG_LEVEL (explicit level)
34
+ 2. DISPATCH_VERBOSE (boolean toggle for DEBUG)
35
+ 3. Default: WARNING (minimal output)
36
+ """
37
+ # Check explicit log level first
38
+ log_level_str = os.environ.get("DISPATCH_LOG_LEVEL", "").upper()
39
+ if log_level_str:
40
+ level_map = {
41
+ "DEBUG": logging.DEBUG,
42
+ "INFO": logging.INFO,
43
+ "WARNING": logging.WARNING,
44
+ "WARN": logging.WARNING,
45
+ "ERROR": logging.ERROR,
46
+ "CRITICAL": logging.CRITICAL,
47
+ }
48
+ if log_level_str in level_map:
49
+ return level_map[log_level_str]
50
+
51
+ # Check verbose flag
52
+ if _parse_bool_env(os.environ.get("DISPATCH_VERBOSE")):
53
+ return logging.DEBUG
54
+
55
+ # Default: WARNING to minimize noise
56
+ # Users see errors and warnings, but not routine info/debug
57
+ return logging.WARNING
58
+
59
+
60
+ def configure_logging(force: bool = False) -> None:
61
+ """Configure logging for the Dispatch Agents SDK.
62
+
63
+ This sets up the SDK logger with appropriate level and format.
64
+ Called automatically when the SDK is imported, but can be called
65
+ again with force=True to reconfigure.
66
+
67
+ Args:
68
+ force: If True, reconfigure even if already configured
69
+ """
70
+ global _logging_configured
71
+
72
+ if _logging_configured and not force:
73
+ return
74
+
75
+ # Get the SDK root logger
76
+ sdk_logger = logging.getLogger(SDK_LOGGER_NAME)
77
+
78
+ # Set the log level
79
+ level = _get_log_level()
80
+ sdk_logger.setLevel(level)
81
+
82
+ # Only add handler if none exist (avoid duplicate handlers on force)
83
+ if not sdk_logger.handlers:
84
+ # Create a handler that writes to stderr
85
+ handler = logging.StreamHandler(sys.stderr)
86
+ handler.setLevel(level)
87
+
88
+ # Format: simple for normal use, more detail for debug
89
+ if level == logging.DEBUG:
90
+ formatter = logging.Formatter(
91
+ "%(asctime)s [%(name)s] %(levelname)s: %(message)s",
92
+ datefmt="%H:%M:%S",
93
+ )
94
+ else:
95
+ formatter = logging.Formatter("%(levelname)s: %(message)s")
96
+
97
+ handler.setFormatter(formatter)
98
+ sdk_logger.addHandler(handler)
99
+
100
+ # Prevent propagation to root logger (avoid duplicate messages)
101
+ sdk_logger.propagate = False
102
+
103
+ _logging_configured = True
104
+
105
+
106
+ def get_logger(name: str | None = None) -> logging.Logger:
107
+ """Get a logger for SDK modules.
108
+
109
+ Args:
110
+ name: Optional module name (e.g., __name__). If provided,
111
+ creates a child logger under SDK_LOGGER_NAME.
112
+
113
+ Returns:
114
+ Configured logger instance
115
+ """
116
+ # Ensure logging is configured
117
+ configure_logging()
118
+
119
+ if name:
120
+ # Create child logger: dispatch_agents.grpc_server, etc.
121
+ if name.startswith(SDK_LOGGER_NAME):
122
+ return logging.getLogger(name)
123
+ return logging.getLogger(f"{SDK_LOGGER_NAME}.{name}")
124
+ return logging.getLogger(SDK_LOGGER_NAME)
125
+
126
+
127
+ def is_verbose() -> bool:
128
+ """Check if verbose logging is enabled.
129
+
130
+ Returns:
131
+ True if DISPATCH_VERBOSE is set or log level is DEBUG
132
+ """
133
+ return _get_log_level() == logging.DEBUG